Files
invyone/frontend/components/common/ResponsiveDataView.tsx
T
hjjeong d61777ab5f 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>
2026-05-15 16:38:23 +09:00

373 lines
13 KiB
TypeScript

"use client";
import React, { ReactNode } from "react";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import { cn } from "@/lib/utils";
// 컬럼 정의 타입
export interface RDVColumn<T> {
key: string;
label: string;
width?: string;
render?: (value: any, row: T, index: number) => ReactNode;
hideOnMobile?: boolean;
className?: string;
}
// 카드 필드 타입
export interface RDVCardField<T> {
label: string;
render: (item: T) => ReactNode;
hideEmpty?: boolean;
}
// 메인 Props 타입
export interface ResponsiveDataViewProps<T> {
data: T[];
columns: RDVColumn<T>[];
keyExtractor: (item: T) => string;
// 로딩/빈 상태
isLoading?: boolean;
emptyMessage?: string;
skeletonCount?: number;
// 카드 설정 (모바일)
cardTitle: (item: T) => ReactNode;
cardSubtitle?: (item: T) => ReactNode;
cardHeaderRight?: (item: T) => ReactNode;
cardFields?: RDVCardField<T>[] | ((item: T) => RDVCardField<T>[]);
// 액션 (테이블 마지막 컬럼 + 카드 하단)
renderActions?: (item: T) => ReactNode;
actionsLabel?: string;
actionsWidth?: string;
// 행 클릭
onRowClick?: (item: T) => void;
// 스타일 커스터마이징
tableContainerClassName?: string;
cardContainerClassName?: string;
// 컴팩트 모드 — 행 높이 줄임 (h-16 → h-10), 큰 데이터셋용
compact?: boolean;
// 자체 스크롤 모드 — 테이블 컨테이너에 max-height + overflow-y-auto 적용,
// 헤더 sticky. 부모가 height 결정 (예: flex-1 min-h-0).
// 페이지 전체 스크롤 대신 테이블 안에서 스크롤 처리.
scrollContainer?: boolean;
}
// 중첩 객체에서 키 경로로 값을 꺼내는 헬퍼
function getNestedValue(obj: any, path: string): any {
return path.split(".").reduce((acc, key) => acc?.[key], obj);
}
export function ResponsiveDataView<T>({
data,
columns,
keyExtractor,
isLoading = false,
emptyMessage,
skeletonCount = 5,
cardTitle,
cardSubtitle,
cardHeaderRight,
cardFields,
renderActions,
actionsLabel,
actionsWidth,
onRowClick,
tableContainerClassName,
cardContainerClassName,
compact = false,
scrollContainer = false,
}: 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);
if (Array.isArray(cardFields)) return cardFields;
return columns
.filter((col) => !col.hideOnMobile)
.map((col) => ({
label: col.label,
render: (row: T) =>
col.render
? col.render(getNestedValue(row, col.key), row, 0)
: String(getNestedValue(row, col.key) ?? "-"),
}));
}
// --- 로딩 스켈레톤 ---
if (isLoading) {
return (
<div
className={cn(
"@container",
scrollContainer && "flex min-h-0 flex-1 flex-col"
)}
>
{/* 데스크톱 테이블 스켈레톤 */}
<div
className={cn(
"hidden rounded-lg border bg-card shadow-sm @3xl:block",
scrollContainer && "min-h-0 flex-1 overflow-y-auto",
tableContainerClassName
)}
>
<Table>
<TableHeader>
<TableRow className="border-b bg-muted/50 hover:bg-muted/50">
{columns.map((col) => (
<TableHead
key={col.key}
style={col.width ? { width: col.width } : undefined}
className="h-12 text-sm font-semibold"
>
{col.label}
</TableHead>
))}
{renderActions && (
<TableHead
style={{ width: actionsWidth || "120px" }}
className="h-12 text-sm font-semibold"
>
{actionsLabel || "작업"}
</TableHead>
)}
</TableRow>
</TableHeader>
<TableBody>
{Array.from({ length: skeletonCount }).map((_, rowIdx) => (
<TableRow key={rowIdx} className="border-b">
{columns.map((col) => (
<TableCell key={col.key} className="h-16">
<div className="h-4 animate-pulse rounded bg-muted" />
</TableCell>
))}
{renderActions && (
<TableCell className="h-16">
<div className="flex gap-2">
<div className="h-8 w-8 animate-pulse rounded bg-muted" />
<div className="h-8 w-8 animate-pulse rounded bg-muted" />
</div>
</TableCell>
)}
</TableRow>
))}
</TableBody>
</Table>
</div>
{/* 모바일 카드 스켈레톤 — container query 기반:
컨테이너 < 32rem(512px) = 1열, 32~48rem = 2열, ≥ 48rem(768px) = 테이블 */}
<div
className={cn(
"grid gap-4 @lg:grid-cols-2 @3xl:hidden",
scrollContainer && "min-h-0 flex-1 overflow-y-auto",
cardContainerClassName
)}
>
{Array.from({ length: skeletonCount }).map((_, i) => (
<div
key={i}
className="rounded-lg border bg-card p-4 shadow-sm"
>
<div className="mb-3 flex items-start justify-between">
<div className="flex-1 space-y-2">
<div className="h-5 w-32 animate-pulse rounded bg-muted" />
<div className="h-4 w-24 animate-pulse rounded bg-muted" />
</div>
<div className="h-6 w-12 animate-pulse rounded bg-muted" />
</div>
<div className="space-y-2 border-t pt-3">
{Array.from({ length: 3 }).map((_, j) => (
<div key={j} className="flex justify-between">
<div className="h-4 w-16 animate-pulse rounded bg-muted" />
<div className="h-4 w-24 animate-pulse rounded bg-muted" />
</div>
))}
</div>
{renderActions && (
<div className="mt-3 flex gap-2 border-t pt-3">
<div className="h-9 flex-1 animate-pulse rounded bg-muted" />
<div className="h-9 flex-1 animate-pulse rounded bg-muted" />
</div>
)}
</div>
))}
</div>
</div>
);
}
// --- 빈 상태 ---
if (data.length === 0) {
return (
<div className="flex h-32 items-center justify-center rounded-lg border bg-card text-sm text-muted-foreground">
{emptyMessage || "데이터가 없습니다."}
</div>
);
}
// --- 실제 데이터 렌더링 ---
// 부모 wrapper 가 @container — 자식들은 viewport 가 아닌 자기 컨테이너 width 기준으로 분기.
// 사이드바 펼친 상태에서도 콘텐츠 영역 실제 width 에 맞게 자연스럽게 테이블↔카드 전환.
return (
<div
className={cn(
"@container",
// scrollContainer 모드: 부모 flex-col 의 남는 공간을 차지 → 페이지네이션 등 형제는 자기 height 유지
scrollContainer && "flex min-h-0 flex-1 flex-col"
)}
>
{/* 데스크톱 테이블 (컨테이너 ≥ 48rem / 768px) */}
<div
className={cn(
// 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 divClassName={scrollContainer ? "flex-1 overflow-auto" : undefined}>
<TableHeader
className={cn(
scrollContainer && "sticky top-0 z-10 bg-muted"
)}
>
<TableRow className="border-b bg-muted/50 hover:bg-muted/50">
{columns.map((col) => (
<TableHead
key={col.key}
style={col.width ? { width: col.width } : undefined}
className={cn(headHeight, cellPad, headText, "font-semibold")}
>
{col.label}
</TableHead>
))}
{renderActions && (
<TableHead
style={{ width: actionsWidth || "120px" }}
className={cn(headHeight, cellPad, headText, "font-semibold")}
>
{actionsLabel || "작업"}
</TableHead>
)}
</TableRow>
</TableHeader>
<TableBody>
{data.map((item, index) => (
<TableRow
key={keyExtractor(item) ?? `row-${index}`}
className={cn(
"border-b transition-colors hover:bg-muted/50",
onRowClick && "cursor-pointer"
)}
onClick={() => onRowClick?.(item)}
>
{columns.map((col) => (
<TableCell
key={col.key}
className={cn(rowHeight, cellPad, bodyText, col.className)}
>
{col.render
? col.render(getNestedValue(item, col.key), item, index)
: String(getNestedValue(item, col.key) ?? "-")}
</TableCell>
))}
{renderActions && (
<TableCell className={cn(rowHeight, cellPad, bodyText)}>
<div className="flex gap-2">{renderActions(item)}</div>
</TableCell>
)}
</TableRow>
))}
</TableBody>
</Table>
</div>
{/* 모바일 카드 (컨테이너 < 48rem) — < 32rem 1열, 32~48rem 2열 */}
<div
className={cn(
"grid gap-4 @lg:grid-cols-2 @3xl:hidden",
// scrollContainer 모드: 부모 flex 안에서 남는 공간 다 차지 + 자체 세로 스크롤
scrollContainer && "min-h-0 flex-1 overflow-y-auto",
cardContainerClassName
)}
>
{data.map((item, index) => {
const fields = resolveCardFields(item);
return (
<div
key={keyExtractor(item) ?? `card-${index}`}
className={cn(
"rounded-lg border bg-card p-4 shadow-sm transition-colors hover:bg-muted/50",
onRowClick && "cursor-pointer"
)}
onClick={() => onRowClick?.(item)}
>
{/* 카드 헤더 */}
<div className="mb-3 flex items-start justify-between">
<div className="min-w-0 flex-1">
<h3 className={cn("truncate font-semibold", cardTitleClass)}>
{cardTitle(item)}
</h3>
{cardSubtitle && (
<p className={cn("mt-0.5 truncate text-muted-foreground", cardSubText)}>
{cardSubtitle(item)}
</p>
)}
</div>
{cardHeaderRight && (
<div className="ml-2 shrink-0">{cardHeaderRight(item)}</div>
)}
</div>
{/* 카드 필드 */}
{fields.length > 0 && (
<div className="space-y-1.5 border-t pt-3">
{fields.map((field, i) => (
<div key={i} className={cn("flex justify-between", cardSubText)}>
<span className="text-muted-foreground">
{field.label}
</span>
<span className="font-medium">{field.render(item)}</span>
</div>
))}
</div>
)}
{/* 카드 액션 */}
{renderActions && (
<div className="mt-3 flex gap-2 border-t pt-3">
{renderActions(item)}
</div>
)}
</div>
);
})}
</div>
</div>
);
}