fix(admin): 외부커넥션 mapper varchar 캐스팅 + 외부커넥션/배치관리 UI 정돈 #21
@@ -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'
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user