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

- mapper/externalDbConnection.xml: WHERE ID = #{id} 5곳 + ID != #{exclude_id} 1곳에 ::varchar 캐스팅 추가
  (EXTERNAL_DB_CONNECTIONS.ID 가 V001 마이그레이션으로 VARCHAR 인데 long 바인딩되어 character varying = bigint 비교 불가로 500 발생하던 것을 해결)
- exconList: 페이지 overflow-hidden + Tabs/TabsContent 가 flex 컨테이너, ResponsiveDataView scrollContainer 활성화로 테이블 안에서만 sticky header + 자체 스크롤
- exconList/RestApiConnectionList: text-3xl→text-lg/text-sm→text-xs/h-10→h-8 등 컴팩트 폰트로 통일 (배치관리/플로우관리와 톤 매칭)
- RestApiConnectionList: Table divClassName 으로 wrapper 자체에 스크롤 위임 + sticky TableHeader 적용
- ResponsiveDataView: compact 모드일 때 폰트/셀패딩/카드 폰트도 함께 축소, scrollContainer 모드에서 @3xl:block 이 flex 를 덮어쓰던 우선순위 충돌 해결, sticky header 알파 제거
- batchmngList: Pagination 컴포넌트 적용 (RPS batchmngList 참고, 페이지당 10/20/50/100 선택), 컨테이너를 h-full min-h-0 overflow-hidden + 리스트만 자체 스크롤로 변경

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
hjjeong
2026-05-15 16:38:23 +09:00
parent f53307a72e
commit d61777ab5f
5 changed files with 160 additions and 110 deletions
@@ -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>
@@ -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>
@@ -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>