summaryrefslogtreecommitdiff
path: root/components/data-table
diff options
context:
space:
mode:
authordujinkim <dujin.kim@dtsolution.co.kr>2025-06-01 13:52:21 +0000
committerdujinkim <dujin.kim@dtsolution.co.kr>2025-06-01 13:52:21 +0000
commitbac0228d21b7195065e9cddcc327ae33659c7bcc (patch)
tree8f3016ae4533c8706d0c00a605d9b1d41968c2bc /components/data-table
parent2fdce8d7a57c792bba0ac36fa554dca9c9cc31e3 (diff)
(대표님) 20250601까지 작업사항
Diffstat (limited to 'components/data-table')
-rw-r--r--components/data-table/data-table-pagination.tsx219
-rw-r--r--components/data-table/data-table-pin-right.tsx2
-rw-r--r--components/data-table/data-table.tsx6
-rw-r--r--components/data-table/expandable-data-table.tsx707
-rw-r--r--components/data-table/infinite-data-table.tsx69
-rw-r--r--components/data-table/view-mode-toggle.tsx91
6 files changed, 711 insertions, 383 deletions
diff --git a/components/data-table/data-table-pagination.tsx b/components/data-table/data-table-pagination.tsx
index 922dacf1..4ed63a1b 100644
--- a/components/data-table/data-table-pagination.tsx
+++ b/components/data-table/data-table-pagination.tsx
@@ -7,7 +7,6 @@ import {
ChevronRight,
ChevronsLeft,
ChevronsRight,
- Infinity,
} from "lucide-react"
import { Button } from "@/components/ui/button"
@@ -22,99 +21,57 @@ import {
interface DataTablePaginationProps<TData> {
table: Table<TData>
pageSizeOptions?: Array<number | "All">
- // 무한 스크롤 관련 props
- infiniteScroll?: {
- enabled: boolean
- hasNextPage: boolean
- isLoadingMore: boolean
- totalCount?: number | null
- onLoadMore?: () => void
- }
- // 페이지 크기 변경 콜백 (필수!)
- onPageSizeChange?: (pageSize: number) => void
}
export function DataTablePagination<TData>({
table,
pageSizeOptions = [10, 20, 30, 40, 50, "All"],
- infiniteScroll,
- onPageSizeChange,
}: DataTablePaginationProps<TData>) {
// 현재 테이블 pageSize
const currentPageSize = table.getState().pagination.pageSize
- const isInfiniteMode = infiniteScroll?.enabled || currentPageSize >= 1_000_000
- // "All"을 1,000,000으로 처리하고, 무한 스크롤 모드 표시
- const selectValue = isInfiniteMode ? "All" : String(currentPageSize)
-
- const handlePageSizeChange = (value: string) => {
- if (!onPageSizeChange) {
- console.warn('DataTablePagination: onPageSizeChange prop is required for page size changes to work')
- return
- }
-
- if (value === "All") {
- // "All" 선택 시 무한 스크롤 모드로 전환
- onPageSizeChange(1_000_000) // URL 상태 업데이트만 수행
- } else {
- const newSize = Number(value)
- onPageSizeChange(newSize) // URL 상태 업데이트만 수행
- }
-
- // table.setPageSize()는 호출하지 않음!
- // URL 상태 변경이 테이블 상태로 자동 반영됨
- }
+ // "All"을 1,000,000으로 처리할 것이므로,
+ // 만약 현재 pageSize가 1,000,000이면 화면상 "All"로 표시
+ const selectValue =
+ currentPageSize === 1_000_000
+ ? "All"
+ : String(currentPageSize)
return (
<div className="flex w-full flex-col-reverse items-center justify-between gap-4 overflow-auto p-1 sm:flex-row sm:gap-8">
- {/* 선택된 행 및 총 개수 정보 */}
<div className="flex-1 whitespace-nowrap text-sm text-muted-foreground">
{table.getFilteredSelectedRowModel().rows.length} of{" "}
- {isInfiniteMode ? (
- // 무한 스크롤 모드일 때
- <>
- {table.getRowModel().rows.length} row(s) selected.
- {infiniteScroll?.totalCount !== null && (
- <span className="ml-4">
- Total: {infiniteScroll.totalCount?.toLocaleString()} records
- <span className="ml-2 text-xs">
- ({table.getRowModel().rows.length.toLocaleString()} loaded)
- </span>
- </span>
- )}
- </>
- ) : (
- // 페이지네이션 모드일 때
- <>
- {table.getFilteredRowModel().rows.length} row(s) selected.
- <span className="ml-4">Total: {table.getRowCount()} records</span>
- </>
- )}
+ {table.getFilteredRowModel().rows.length} row(s) selected.
+ <span className="ml-4">Total: {table.getRowCount()} records</span>
</div>
-
<div className="flex flex-col-reverse items-center gap-4 sm:flex-row sm:gap-6 lg:gap-8">
{/* Rows per page Select */}
<div className="flex items-center space-x-2">
- <p className="whitespace-nowrap text-sm font-medium">
- {isInfiniteMode ? "View mode" : "Rows per page"}
- </p>
- <Select value={selectValue} onValueChange={handlePageSizeChange}>
+ <p className="whitespace-nowrap text-sm font-medium">Rows per page</p>
+ <Select
+ value={selectValue}
+ onValueChange={(value) => {
+ if (value === "All") {
+ // "All"을 1,000,000으로 치환
+ table.setPageSize(1_000_000)
+ } else {
+ table.setPageSize(Number(value))
+ }
+ }}
+ >
<SelectTrigger className="h-8 w-[4.5rem]">
<SelectValue placeholder={selectValue} />
</SelectTrigger>
<SelectContent side="top">
{pageSizeOptions.map((option) => {
+ // 화면에 표시할 라벨
const label = option === "All" ? "All" : String(option)
+ // value도 문자열화
const val = option === "All" ? "All" : String(option)
return (
<SelectItem key={val} value={val}>
- <div className="flex items-center space-x-2">
- {option === "All" && (
- <Infinity className="h-3 w-3 text-muted-foreground" />
- )}
- <span>{label}</span>
- </div>
+ {label}
</SelectItem>
)
})}
@@ -122,90 +79,54 @@ export function DataTablePagination<TData>({
</Select>
</div>
- {/* 페이지네이션 모드일 때만 페이지 정보 표시 */}
- {!isInfiniteMode && (
- <>
- {/* 현재 페이지 / 전체 페이지 */}
- <div className="flex items-center justify-center text-sm font-medium">
- Page {table.getState().pagination.pageIndex + 1} of{" "}
- {table.getPageCount()}
- </div>
-
- {/* 페이지 이동 버튼 */}
- <div className="flex items-center space-x-2">
- <Button
- aria-label="Go to first page"
- variant="outline"
- className="hidden size-8 p-0 lg:flex"
- onClick={() => table.setPageIndex(0)}
- disabled={!table.getCanPreviousPage()}
- >
- <ChevronsLeft className="size-4" aria-hidden="true" />
- </Button>
- <Button
- aria-label="Go to previous page"
- variant="outline"
- size="icon"
- className="size-8"
- onClick={() => table.previousPage()}
- disabled={!table.getCanPreviousPage()}
- >
- <ChevronLeft className="size-4" aria-hidden="true" />
- </Button>
- <Button
- aria-label="Go to next page"
- variant="outline"
- size="icon"
- className="size-8"
- onClick={() => table.nextPage()}
- disabled={!table.getCanNextPage()}
- >
- <ChevronRight className="size-4" aria-hidden="true" />
- </Button>
- <Button
- aria-label="Go to last page"
- variant="outline"
- size="icon"
- className="hidden size-8 lg:flex"
- onClick={() => table.setPageIndex(table.getPageCount() - 1)}
- disabled={!table.getCanNextPage()}
- >
- <ChevronsRight className="size-4" aria-hidden="true" />
- </Button>
- </div>
- </>
- )}
+ {/* 현재 페이지 / 전체 페이지 */}
+ <div className="flex items-center justify-center text-sm font-medium">
+ Page {table.getState().pagination.pageIndex + 1} of{" "}
+ {table.getPageCount()}
+ </div>
- {/* 무한 스크롤 모드일 때 로드 더 버튼 */}
- {isInfiniteMode && infiniteScroll && (
- <div className="flex items-center space-x-2">
- {infiniteScroll.hasNextPage && (
- <Button
- variant="outline"
- size="sm"
- onClick={infiniteScroll.onLoadMore}
- disabled={infiniteScroll.isLoadingMore}
- >
- {infiniteScroll.isLoadingMore ? (
- <>
- <div className="mr-2 h-3 w-3 animate-spin rounded-full border-2 border-current border-t-transparent" />
- Loading...
- </>
- ) : (
- <>
- <ChevronRight className="mr-2 h-3 w-3" />
- Load More
- </>
- )}
- </Button>
- )}
- {!infiniteScroll.hasNextPage && table.getRowModel().rows.length > 0 && (
- <span className="text-xs text-muted-foreground">
- All data loaded
- </span>
- )}
- </div>
- )}
+ {/* 페이지 이동 버튼 */}
+ <div className="flex items-center space-x-2">
+ <Button
+ aria-label="Go to first page"
+ variant="outline"
+ className="hidden size-8 p-0 lg:flex"
+ onClick={() => table.setPageIndex(0)}
+ disabled={!table.getCanPreviousPage()}
+ >
+ <ChevronsLeft className="size-4" aria-hidden="true" />
+ </Button>
+ <Button
+ aria-label="Go to previous page"
+ variant="outline"
+ size="icon"
+ className="size-8"
+ onClick={() => table.previousPage()}
+ disabled={!table.getCanPreviousPage()}
+ >
+ <ChevronLeft className="size-4" aria-hidden="true" />
+ </Button>
+ <Button
+ aria-label="Go to next page"
+ variant="outline"
+ size="icon"
+ className="size-8"
+ onClick={() => table.nextPage()}
+ disabled={!table.getCanNextPage()}
+ >
+ <ChevronRight className="size-4" aria-hidden="true" />
+ </Button>
+ <Button
+ aria-label="Go to last page"
+ variant="outline"
+ size="icon"
+ className="hidden size-8 lg:flex"
+ onClick={() => table.setPageIndex(table.getPageCount() - 1)}
+ disabled={!table.getCanNextPage()}
+ >
+ <ChevronsRight className="size-4" aria-hidden="true" />
+ </Button>
+ </div>
</div>
</div>
)
diff --git a/components/data-table/data-table-pin-right.tsx b/components/data-table/data-table-pin-right.tsx
index 2ba471cc..133740e1 100644
--- a/components/data-table/data-table-pin-right.tsx
+++ b/components/data-table/data-table-pin-right.tsx
@@ -84,7 +84,7 @@ function getColumnDisplayName<TData>(column: Column<TData>): string {
/**
* Array of column IDs that should be auto-pinned to the right when available
*/
-const AUTO_PIN_RIGHT_COLUMNS = ['actions']
+const AUTO_PIN_RIGHT_COLUMNS = ['actions', "action"]
/**
* "Pin Right" Popover. Supports pinning both individual columns and header groups.
diff --git a/components/data-table/data-table.tsx b/components/data-table/data-table.tsx
index c241c1ec..7d376b65 100644
--- a/components/data-table/data-table.tsx
+++ b/components/data-table/data-table.tsx
@@ -5,7 +5,7 @@ import { flexRender, type Table as TanstackTable } from "@tanstack/react-table"
import { ChevronRight, ChevronUp } from "lucide-react"
import { cn } from "@/lib/utils"
-import { getCommonPinningStyles } from "@/lib/data-table"
+import { getCommonPinningStylesWithBorder } from "@/lib/data-table"
import {
Table,
TableBody,
@@ -75,7 +75,7 @@ export function DataTable<TData>({
data-column-id={header.column.id}
className={compact ? "py-1 px-2 text-sm" : ""}
style={{
- ...getCommonPinningStyles({ column: header.column }),
+ ...getCommonPinningStylesWithBorder({ column: header.column }),
width: header.getSize(),
}}
>
@@ -170,7 +170,7 @@ export function DataTable<TData>({
data-column-id={cell.column.id}
className={compactStyles.cell}
style={{
- ...getCommonPinningStyles({ column: cell.column }),
+ ...getCommonPinningStylesWithBorder({ column: cell.column }),
width: cell.column.getSize(),
}}
>
diff --git a/components/data-table/expandable-data-table.tsx b/components/data-table/expandable-data-table.tsx
index 9005e2fb..112e9448 100644
--- a/components/data-table/expandable-data-table.tsx
+++ b/components/data-table/expandable-data-table.tsx
@@ -5,7 +5,7 @@ import { flexRender, type Table as TanstackTable } from "@tanstack/react-table"
import { ChevronRight, ChevronUp, ChevronDown } from "lucide-react"
import { cn } from "@/lib/utils"
-import { getCommonPinningStyles } from "@/lib/data-table"
+import { getCommonPinningStylesWithBorder, debugPinningInfo } from "@/lib/data-table"
import {
Table,
TableBody,
@@ -26,18 +26,13 @@ interface ExpandableDataTableProps<TData> extends React.HTMLAttributes<HTMLDivEl
expandedRows?: Set<string>
setExpandedRows?: React.Dispatch<React.SetStateAction<Set<string>>>
renderExpandedContent?: (row: TData) => React.ReactNode
- expandable?: boolean // 확장 기능 활성화 여부
+ expandable?: boolean
maxHeight?: string | number
- expandedRowClassName?: string // 확장된 행의 커스텀 클래스
+ expandedRowClassName?: string
+ debug?: boolean // 디버깅 옵션 추가
+ simpleExpansion?: boolean // 🎯 간단한 확장 방식 선택 옵션 추가
}
-/**
- * 확장 가능한 데이터 테이블 - 행 확장 시 바로 아래에 컨텐츠 표시
- * 개선사항:
- * - 가로스크롤과 확장된 내용 독립성 보장
- * - 동적 높이 계산으로 세로스크롤 문제 해결
- * - 향상된 접근성 및 키보드 네비게이션
- */
export function ExpandableDataTable<TData>({
table,
floatingBar = null,
@@ -49,6 +44,8 @@ export function ExpandableDataTable<TData>({
expandable = false,
maxHeight,
expandedRowClassName,
+ debug = false,
+ simpleExpansion = false, // 🎯 기본값 false (전체 확장 방식)
children,
className,
...props
@@ -56,11 +53,228 @@ export function ExpandableDataTable<TData>({
useAutoSizeColumns(table, autoSizeColumns)
- // 스크롤 컨테이너 참조
- const scrollContainerRef = React.useRef<HTMLDivElement>(null)
+ const containerRef = React.useRef<HTMLDivElement>(null)
+ const scrollRef = React.useRef<HTMLDivElement>(null) // 🎯 스크롤 컨테이너 ref 추가
const [expandedHeights, setExpandedHeights] = React.useState<Map<string, number>>(new Map())
+ const [isScrolled, setIsScrolled] = React.useState(false)
+ const [isScrolledToEnd, setIsScrolledToEnd] = React.useState(true) // 🎯 초기값을 true로 설정 (아직 계산 안됨)
+ const [isInitialized, setIsInitialized] = React.useState(false) // 🎯 초기화 상태 추가
+ const [containerWidth, setContainerWidth] = React.useState<number>(0)
- // 행 확장/축소 핸들러 (개선된 버전)
+ // 🎯 컨테이너 너비 감지 (패딩 제외된 실제 사용 가능한 너비)
+ React.useEffect(() => {
+ if (!containerRef.current) return
+
+ const updateContainerWidth = () => {
+ if (containerRef.current) {
+ // clientWidth는 패딩을 제외한 실제 사용 가능한 너비
+ setContainerWidth(containerRef.current.clientWidth)
+ }
+ }
+
+ const resizeObserver = new ResizeObserver(updateContainerWidth)
+ resizeObserver.observe(containerRef.current)
+
+ // 초기 너비 설정
+ updateContainerWidth()
+
+ return () => resizeObserver.disconnect()
+ }, [])
+
+ // 🎯 초기 스크롤 상태 체크 (더 확실한 초기화)
+ React.useEffect(() => {
+ if (!scrollRef.current) return
+
+ const checkScrollState = () => {
+ if (scrollRef.current) {
+ const { scrollLeft, scrollWidth, clientWidth } = scrollRef.current
+ const newIsScrolled = scrollLeft > 0
+ const newIsScrolledToEnd = Math.abs(scrollWidth - clientWidth - scrollLeft) < 1
+
+ setIsScrolled(newIsScrolled)
+ setIsScrolledToEnd(newIsScrolledToEnd)
+ setIsInitialized(true)
+
+ if (debug) {
+ console.log('Initial scroll check:', {
+ scrollLeft,
+ scrollWidth,
+ clientWidth,
+ newIsScrolled,
+ newIsScrolledToEnd,
+ canScroll: scrollWidth > clientWidth
+ })
+ }
+ }
+ }
+
+ // 즉시 체크
+ checkScrollState()
+
+ // 짧은 지연 후 재체크 (테이블 렌더링 완료 대기)
+ const timeouts = [50, 100, 200].map(delay =>
+ setTimeout(checkScrollState, delay)
+ )
+
+ // ResizeObserver로 테이블 크기 변화 감지
+ const resizeObserver = new ResizeObserver(() => {
+ setTimeout(checkScrollState, 10)
+ })
+ resizeObserver.observe(scrollRef.current)
+
+ return () => {
+ timeouts.forEach(clearTimeout)
+ resizeObserver.disconnect()
+ }
+ }, [table, debug])
+
+ // 🎯 데이터 변경 시 스크롤 상태 재설정
+ React.useEffect(() => {
+ setIsInitialized(false) // 🎯 데이터 변경 시 초기화 상태 리셋
+ setIsScrolledToEnd(true) // 🎯 안전한 기본값으로 리셋
+ }, [table.getRowModel().rows.length])
+
+ const handleScroll = (e: React.UIEvent<HTMLDivElement>) => {
+ const scrollLeft = e.currentTarget.scrollLeft
+ const scrollWidth = e.currentTarget.scrollWidth
+ const clientWidth = e.currentTarget.clientWidth
+
+ // 🎯 왼쪽으로부터 스크롤 상태 (왼쪽 핀용)
+ setIsScrolled(scrollLeft > 0)
+
+ // 🎯 오른쪽 끝까지 스크롤 상태 (오른쪽 핀용)
+ const isAtEnd = Math.abs(scrollWidth - clientWidth - scrollLeft) < 1 // 1px 오차 허용
+ setIsScrolledToEnd(isAtEnd)
+ setIsInitialized(true) // 🎯 스크롤 이벤트 발생 시 초기화 완료
+
+ if (debug) {
+ console.log('Scroll state:', {
+ scrollLeft,
+ scrollWidth,
+ clientWidth,
+ isScrolled: scrollLeft > 0,
+ isScrolledToEnd: isAtEnd,
+ remainingScroll: scrollWidth - clientWidth - scrollLeft
+ })
+ }
+ }
+
+ // 🔧 개선된 핀 스타일 함수 (좌/우 핀 구분 처리)
+ const getPinnedStyle = React.useCallback((column: any, isHeader: boolean = false) => {
+ if (debug) {
+ debugPinningInfo(column)
+ }
+
+ try {
+ const baseStyle = getCommonPinningStylesWithBorder({
+ column,
+ withBorder: true
+ })
+
+ const pinnedSide = column.getIsPinned()
+
+ if (!pinnedSide) {
+ // width를 제외한 나머지 스타일만 반환
+ const { width, ...restStyle } = baseStyle
+ // 헤더인 경우 핀되지 않았어도 배경 필요 (sticky 때문에)
+ return {
+ ...restStyle,
+ ...(isHeader && {
+ background: "hsl(var(--background))",
+ transition: "none",
+ }),
+ }
+ }
+
+ // 확장 버튼이 있을 때 left pin된 컬럼들을 오른쪽으로 이동
+ let leftPosition = baseStyle.left
+ if (expandable && pinnedSide === "left") {
+ const expandButtonWidth = 40 // w-10 = 40px
+ if (typeof baseStyle.left === 'string') {
+ const currentLeft = parseFloat(baseStyle.left.replace('px', ''))
+ leftPosition = `${currentLeft + expandButtonWidth}px`
+ } else if (typeof baseStyle.left === 'number') {
+ leftPosition = `${baseStyle.left + expandButtonWidth}px`
+ } else {
+ leftPosition = `${expandButtonWidth}px`
+ }
+ }
+
+ // 🎯 핀 위치에 따른 배경 결정
+ let shouldShowBackground = false
+ if (isHeader) {
+ // 헤더는 항상 배경 표시
+ shouldShowBackground = true
+ } else {
+ // 바디 셀의 경우 핀 위치에 따라 다른 조건 적용
+ if (pinnedSide === "left") {
+ // 왼쪽 핀: 오른쪽으로 스크롤했을 때 배경 표시
+ shouldShowBackground = isScrolled
+ } else if (pinnedSide === "right") {
+ // 오른쪽 핀: 초기화 전이거나 오른쪽 끝까지 스크롤하지 않았을 때 배경 표시
+ shouldShowBackground = !isInitialized || !isScrolledToEnd
+ }
+ }
+
+ // width를 제외한 스타일 적용
+ const { width, ...restBaseStyle } = baseStyle
+
+ const finalStyle = {
+ ...restBaseStyle,
+ left: leftPosition,
+ background: shouldShowBackground
+ ? "hsl(var(--background))"
+ : "transparent",
+ transition: isHeader ? "none" : "background-color 0.15s ease-out",
+ }
+
+ if (debug) {
+ console.log("Final pinned style:", {
+ columnId: column.id,
+ pinnedSide,
+ isHeader,
+ isScrolled,
+ isScrolledToEnd,
+ isInitialized,
+ shouldShowBackground,
+ finalStyle
+ })
+ }
+
+ return finalStyle
+ } catch (error) {
+ console.error("Error in getPinnedStyle:", error)
+ // fallback 스타일
+ return {
+ position: 'relative' as const,
+ ...(isHeader && {
+ background: "hsl(var(--background))",
+ }),
+ }
+ }
+ }, [expandable, isScrolled, isScrolledToEnd, isInitialized, debug])
+
+ // 확장 버튼용 스타일 (안정성 개선)
+ const getExpandButtonStyle = React.useCallback(() => {
+ return {
+ position: 'sticky' as const,
+ left: 0,
+ zIndex: 1,
+ background: "hsl(var(--background))",
+ minWidth: '40px',
+ maxWidth: '40px',
+ width: '40px',
+ }
+ }, [])
+
+ // 🎯 테이블 총 너비 계산
+ const getTableWidth = React.useCallback(() => {
+ const expandButtonWidth = expandable ? 40 : 0
+ const totalSize = table.getCenterTotalSize() + table.getLeftTotalSize() + table.getRightTotalSize()
+ return Math.max(totalSize + expandButtonWidth, 800) // 최소 800px 보장
+ }, [table, expandable])
+
+ // 행 확장/축소 핸들러
const toggleRowExpansion = React.useCallback((rowId: string, event?: React.MouseEvent) => {
if (!setExpandedRows) return
@@ -71,7 +285,6 @@ export function ExpandableDataTable<TData>({
const newExpanded = new Set(expandedRows)
if (newExpanded.has(rowId)) {
newExpanded.delete(rowId)
- // 높이 정보도 제거
setExpandedHeights(prev => {
const newHeights = new Map(prev)
newHeights.delete(rowId)
@@ -91,7 +304,66 @@ export function ExpandableDataTable<TData>({
}
}, [toggleRowExpansion])
- // 확장된 내용의 높이 측정 및 업데이트
+ // 🎯 확장된 내용 스타일 계산 함수 (컨테이너 너비 기준)
+ const getExpandedContentStyle = React.useCallback(() => {
+ const expandButtonWidth = expandable ? 40 : 0
+ const availableWidth = containerWidth || 800 // 🎯 컨테이너 너비 사용 (fallback 800px)
+ const contentWidth = availableWidth - expandButtonWidth - 32 // 버튼 + 여백(16px * 2) 제외
+
+ // 🎯 디버그 정보
+ if (debug) {
+ console.log('Expanded content sizing:', {
+ containerWidth,
+ availableWidth,
+ expandButtonWidth,
+ contentWidth,
+ finalWidth: Math.max(contentWidth, 300)
+ })
+ }
+
+ return {
+ width: `${Math.max(contentWidth, 300)}px`, // 🎯 최소 300px 보장
+ marginLeft: `${expandButtonWidth + 8}px`, // 🎯 확장 버튼 + 여백
+ marginRight: '16px',
+ padding: '12px 16px',
+ backgroundColor: 'hsl(var(--background))',
+ borderRadius: '0 0 6px 6px',
+ boxShadow: '0 2px 4px -1px rgba(0, 0, 0, 0.1)',
+ border: '1px solid hsl(var(--border))',
+ borderTop: 'none',
+ marginBottom: '4px',
+ maxWidth: `${availableWidth - expandButtonWidth - 16}px`, // 🎯 컨테이너 너비 초과 방지
+ overflow: 'auto',
+ }
+ }, [expandable, containerWidth, debug])
+
+ // 🎯 간단한 확장 스타일 (컨테이너 너비 기준)
+ const getSimpleExpandedStyle = React.useCallback(() => {
+ const expandButtonWidth = expandable ? 40 : 0
+ const availableWidth = containerWidth || 800
+ const contentWidth = availableWidth - expandButtonWidth - 48 // 버튼 + 여백 제외
+
+ return {
+ marginLeft: `${expandButtonWidth + 8}px`,
+ marginRight: '16px',
+ width: `${Math.max(contentWidth, 300)}px`,
+ maxWidth: `${availableWidth - expandButtonWidth - 24}px`,
+ padding: '12px 16px',
+ backgroundColor: 'hsl(var(--muted) / 0.5)',
+ borderRadius: '6px',
+ border: '1px solid hsl(var(--border))',
+ marginTop: '8px',
+ marginBottom: '8px',
+ overflow: 'auto',
+ }
+ }, [expandable, containerWidth])
+
+ // 🎯 사용할 확장 스타일 선택 (props로 제어)
+ const useSimpleExpansion = simpleExpansion
+ const getExpandedPlaceholderHeight = React.useCallback((contentHeight: number) => {
+ const padding = 24 + 4 // padding (12px * 2) + marginBottom (4px)
+ return Math.max(contentHeight + padding, 220)
+ }, [])
const updateExpandedHeight = React.useCallback((rowId: string, height: number) => {
setExpandedHeights(prev => {
if (prev.get(rowId) !== height) {
@@ -103,22 +375,26 @@ export function ExpandableDataTable<TData>({
})
}, [])
- // 컴팩트 모드를 위한 클래스 정의 (개선된 버전)
+ // 컴팩트 모드 스타일
const compactStyles = compact ? {
row: "h-7",
cell: "py-1 px-2 text-sm",
+ header: "py-1 px-2 text-sm",
+ headerRow: "h-8",
expandedCell: "py-2 px-4",
groupRow: "py-1 bg-muted/20 text-sm",
emptyRow: "h-16",
} : {
row: "",
cell: "",
- expandedCell: "py-0 px-0", // 패딩 제거하여 확장 컨텐츠가 전체 영역 사용
+ header: "",
+ headerRow: "",
+ expandedCell: "py-0 px-0",
groupRow: "bg-muted/20",
emptyRow: "h-24",
}
- // 확장 버튼 렌더링 함수 (접근성 개선)
+ // 확장 버튼 렌더링
const renderExpandButton = (rowId: string) => {
if (!expandable || !setExpandedRows) return null
@@ -143,7 +419,7 @@ export function ExpandableDataTable<TData>({
)
}
- // 확장된 내용 래퍼 컴포넌트 (높이 측정 기능 포함)
+ // 확장된 내용 래퍼 컴포넌트
const ExpandedContentWrapper = React.memo<{
rowId: string
children: React.ReactNode
@@ -176,244 +452,233 @@ export function ExpandableDataTable<TData>({
<div className={cn("w-full space-y-2.5", className)} {...props}>
{children}
- {/* 메인 테이블 컨테이너 - 가로스크롤 문제 해결 */}
<div
- ref={scrollContainerRef}
+ ref={containerRef} // 🎯 컨테이너 wrapper ref (패딩 제외 너비 계산용)
className="relative rounded-md border"
style={{
- // maxHeight: maxHeight || '35rem',
- minHeight: '200px' // 최소 높이 보장
+ minHeight: '200px'
}}
>
- {/* 가로스크롤 영역 */}
- <div className="overflow-x-auto overflow-y-hidden h-full">
- {/* 세로스크롤 영역 (확장된 내용 포함) */}
- <div className="overflow-y-auto h-full">
- <Table className="[&>thead]:sticky [&>thead]:top-0 [&>thead]:z-10 table-fixed">
- {/* 테이블 헤더 */}
- <TableHeader className="bg-background">
- {table.getHeaderGroups().map((headerGroup) => (
- <TableRow key={headerGroup.id} className={compact ? "h-8" : ""}>
- {/* 확장 버튼 컬럼 헤더 */}
- {expandable && (
- <TableHead
- className={cn("w-10 bg-background", compact ? "py-1 px-2" : "")}
- style={{ position: 'sticky', left: 0, zIndex: 11 }}
- />
- )}
-
- {headerGroup.headers.map((header) => {
- if (header.column.getIsGrouped()) {
- return null
- }
-
- return (
- <TableHead
- key={header.id}
- colSpan={header.colSpan}
- data-column-id={header.column.id}
- className={cn(
- compact ? "py-1 px-2 text-sm" : "",
- "bg-background"
- )}
- style={{
- ...getCommonPinningStyles({ column: header.column }),
- width: header.getSize(),
- }}
- >
- <div style={{ position: "relative" }}>
- {header.isPlaceholder
- ? null
- : flexRender(
- header.column.columnDef.header,
- header.getContext()
- )}
+ <div
+ ref={scrollRef} // 🎯 스크롤 컨테이너 ref 연결
+ className="overflow-auto"
+ style={{ maxHeight: maxHeight || '34rem' }}
+ onScroll={handleScroll} // 🎯 스크롤 이벤트 핸들러
+ >
+ <Table
+ className="[&>thead]:sticky [&>thead]:top-0 [&>thead]:z-10"
+ style={{
+ width: getTableWidth(), // 🎯 동적 너비 계산
+ minWidth: '100%'
+ }}
+ >
+ <TableHeader className="bg-background">
+ {table.getHeaderGroups().map((headerGroup) => (
+ <TableRow key={headerGroup.id} className={compactStyles.headerRow}>
+ {expandable && (
+ <TableHead
+ className={cn("w-10", compactStyles.header)}
+ style={getExpandButtonStyle()}
+ />
+ )}
+
+ {headerGroup.headers.map((header) => {
+ if (header.column.getIsGrouped()) {
+ return null
+ }
- {header.column.getCanResize() && (
- <DataTableResizer header={header} />
+ return (
+ <TableHead
+ key={header.id}
+ colSpan={header.colSpan}
+ data-column-id={header.column.id}
+ className={cn(
+ compactStyles.header,
+ "bg-background"
+ )}
+ style={{
+ ...getPinnedStyle(header.column, true), // 🎯 동적 스타일
+ width: header.getSize() // 🎯 width 별도 설정
+ }}
+ >
+ <div style={{ position: "relative" }}>
+ {header.isPlaceholder
+ ? null
+ : flexRender(
+ header.column.columnDef.header,
+ header.getContext()
)}
- </div>
- </TableHead>
- )
- })}
- </TableRow>
- ))}
- </TableHeader>
-
- {/* 테이블 바디 */}
- <TableBody>
- {table.getRowModel().rows?.length ? (
- table.getRowModel().rows.map((row) => {
- const isExpanded = expandedRows.has(row.id)
- const expandedHeight = expandedHeights.get(row.id) || 0
-
- // 그룹핑 헤더 Row
- if (row.getIsGrouped()) {
- const groupingColumnId = row.groupingColumnId ?? ""
- const groupingColumn = table.getColumn(groupingColumnId)
-
- let columnLabel = groupingColumnId
- if (groupingColumn) {
- const headerDef = groupingColumn.columnDef.meta?.excelHeader
- if (typeof headerDef === "string") {
- columnLabel = headerDef
- }
- }
- return (
- <TableRow
- key={row.id}
- className={compactStyles.groupRow}
- data-state={row.getIsExpanded() && "expanded"}
- >
- <TableCell
- colSpan={table.getVisibleFlatColumns().length + (expandable ? 1 : 0)}
- className={compact ? "py-1 px-2" : ""}
- >
- {row.getCanExpand() && (
- <button
- onClick={row.getToggleExpandedHandler()}
- className="inline-flex items-center justify-center mr-2 w-5 h-5 hover:bg-gray-100 rounded focus:outline-none focus:ring-2 focus:ring-blue-500"
- style={{
- marginLeft: `${row.depth * 1.5}rem`,
- }}
- aria-label={row.getIsExpanded() ? "그룹 축소" : "그룹 확장"}
- >
- {row.getIsExpanded() ? (
- <ChevronUp size={compact ? 14 : 16} />
- ) : (
- <ChevronRight size={compact ? 14 : 16} />
- )}
- </button>
- )}
+ {header.column.getCanResize() && (
+ <DataTableResizer header={header} />
+ )}
+ </div>
+ </TableHead>
+ )
+ })}
+ </TableRow>
+ ))}
+ </TableHeader>
- <span className="font-semibold">
- {columnLabel}: {row.getValue(groupingColumnId)}
- </span>
- <span className="ml-2 text-xs text-muted-foreground">
- ({row.subRows.length} rows)
- </span>
- </TableCell>
- </TableRow>
- )
+ <TableBody>
+ {table.getRowModel().rows?.length ? (
+ table.getRowModel().rows.map((row) => {
+ const isExpanded = expandedRows.has(row.id)
+ const expandedHeight = expandedHeights.get(row.id) || 0
+
+ if (row.getIsGrouped()) {
+ const groupingColumnId = row.groupingColumnId ?? ""
+ const groupingColumn = table.getColumn(groupingColumnId)
+
+ let columnLabel = groupingColumnId
+ if (groupingColumn) {
+ const headerDef = groupingColumn.columnDef.meta?.excelHeader
+ if (typeof headerDef === "string") {
+ columnLabel = headerDef
+ }
}
- // 일반 Row와 확장된 컨텐츠를 함께 렌더링
return (
- <React.Fragment key={row.id}>
- {/* 메인 데이터 행 */}
- <TableRow
- className={cn(
- compactStyles.row,
- isExpanded && "bg-muted/30 border-b-0"
- )}
- data-state={row.getIsSelected() && "selected"}
+ <TableRow
+ key={row.id}
+ className={compactStyles.groupRow}
+ data-state={row.getIsExpanded() && "expanded"}
+ >
+ <TableCell
+ colSpan={table.getVisibleFlatColumns().length + (expandable ? 1 : 0)}
+ className={compact ? "py-1 px-2" : ""}
>
- {/* 확장 버튼 셀 */}
- {expandable && (
- <TableCell
- className={cn("w-10", compactStyles.cell)}
- style={{
- position: 'sticky',
- left: 0,
- zIndex: 1,
- backgroundColor: isExpanded ? 'rgb(248 250 252)' : 'white'
+ {row.getCanExpand() && (
+ <button
+ onClick={row.getToggleExpandedHandler()}
+ className="inline-flex items-center justify-center mr-2 w-5 h-5 hover:bg-gray-100 rounded focus:outline-none focus:ring-2 focus:ring-blue-500"
+ style={{
+ marginLeft: `${row.depth * 1.5}rem`,
}}
+ aria-label={row.getIsExpanded() ? "그룹 축소" : "그룹 확장"}
>
- {renderExpandButton(row.id)}
- </TableCell>
+ {row.getIsExpanded() ? (
+ <ChevronUp size={compact ? 14 : 16} />
+ ) : (
+ <ChevronRight size={compact ? 14 : 16} />
+ )}
+ </button>
)}
- {/* 데이터 셀들 */}
- {row.getVisibleCells().map((cell) => {
- if (cell.column.getIsGrouped()) {
- return null
- }
-
- return (
- <TableCell
- key={cell.id}
- data-column-id={cell.column.id}
- className={compactStyles.cell}
- style={{
- ...getCommonPinningStyles({ column: cell.column }),
- width: cell.column.getSize(),
- }}
- >
- {flexRender(
- cell.column.columnDef.cell,
- cell.getContext()
- )}
- </TableCell>
- )
- })}
- </TableRow>
+ <span className="font-semibold">
+ {columnLabel}: {row.getValue(groupingColumnId)}
+ </span>
+ <span className="ml-2 text-xs text-muted-foreground">
+ ({row.subRows.length} rows)
+ </span>
+ </TableCell>
+ </TableRow>
+ )
+ }
+
+ return (
+ <React.Fragment key={row.id}>
+ <TableRow
+ className={cn(
+ compactStyles.row,
+ isExpanded && "bg-muted/30 border-b-0"
+ )}
+ data-state={row.getIsSelected() && "selected"}
+ >
+ {expandable && (
+ <TableCell
+ className={cn("w-10", compactStyles.cell)}
+ style={getExpandButtonStyle()}
+ >
+ {renderExpandButton(row.id)}
+ </TableCell>
+ )}
- {/* 확장된 컨텐츠 행 - 가로스크롤 독립성 보장 */}
- {isExpanded && renderExpandedContent && (
- <TableRow className="hover:bg-transparent">
+ {row.getVisibleCells().map((cell) => {
+ if (cell.column.getIsGrouped()) {
+ return null
+ }
+
+ return (
<TableCell
- colSpan={table.getVisibleFlatColumns().length + (expandable ? 1 : 0)}
- className={cn(
- compactStyles.expandedCell,
- "border-t-0 relative",
- expandedRowClassName
- )}
+ key={cell.id}
+ data-column-id={cell.column.id}
+ className={compactStyles.cell}
style={{
- minHeight: expandedHeight || 'auto',
+ ...getPinnedStyle(cell.column, false), // 🎯 동적 스타일
+ width: cell.column.getSize() // 🎯 width 별도 설정
}}
>
- {/* 확장된 내용을 위한 고정 폭 컨테이너 */}
+ {flexRender(
+ cell.column.columnDef.cell,
+ cell.getContext()
+ )}
+ </TableCell>
+ )
+ })}
+ </TableRow>
+
+ {isExpanded && renderExpandedContent && (
+ <TableRow className="hover:bg-transparent">
+ <TableCell
+ colSpan={table.getVisibleFlatColumns().length + (expandable ? 1 : 0)}
+ className={cn(
+ compactStyles.expandedCell,
+ "border-t-0 relative",
+ expandedRowClassName
+ )}
+ style={{
+ minHeight: expandedHeight || 'auto',
+ }}
+ >
+ {useSimpleExpansion ? (
+ // 🎯 간단한 확장 방식: 테이블 내부에서만 확장
+ <div style={getSimpleExpandedStyle()}>
+ <ExpandedContentWrapper rowId={row.id}>
+ {renderExpandedContent(row.original)}
+ </ExpandedContentWrapper>
+ </div>
+ ) : (
+ // 🎯 전체 화면 확장 방식: 테이블 너비 기준으로 개선
<div className="relative w-full">
- {/* 가로스크롤과 독립적인 확장 영역 */}
<div
- className="absolute left-0 right-0 top-0 border-t"
- style={{
- width: '80vw',
- marginLeft: 'calc(-48vw + 50%)',
- }}
+ className="absolute top-0"
+ style={getExpandedContentStyle()}
>
- <div className="max-w-none mx-auto">
- <ExpandedContentWrapper rowId={row.id}>
- {renderExpandedContent(row.original)}
- </ExpandedContentWrapper>
- </div>
+ <ExpandedContentWrapper rowId={row.id}>
+ {renderExpandedContent(row.original)}
+ </ExpandedContentWrapper>
</div>
- {/* 높이 유지를 위한 스페이서 */}
<div
className="opacity-0 pointer-events-none"
- style={{ height: Math.max(expandedHeight, 200) }}
+ style={{ height: getExpandedPlaceholderHeight(expandedHeight) }}
/>
</div>
- </TableCell>
- </TableRow>
- )}
- </React.Fragment>
- )
- })
- ) : (
- // 데이터가 없을 때
- <TableRow>
- <TableCell
- colSpan={table.getAllColumns().length + (expandable ? 1 : 0)}
- className={compactStyles.emptyRow + " text-center"}
- >
- No results.
- </TableCell>
- </TableRow>
- )}
- </TableBody>
- </Table>
- </div>
+ )}
+ </TableCell>
+ </TableRow>
+ )}
+ </React.Fragment>
+ )
+ })
+ ) : (
+ <TableRow>
+ <TableCell
+ colSpan={table.getAllColumns().length + (expandable ? 1 : 0)}
+ className={compactStyles.emptyRow + " text-center"}
+ >
+ No results.
+ </TableCell>
+ </TableRow>
+ )}
+ </TableBody>
+ </Table>
</div>
</div>
<div className="flex flex-col gap-2.5">
- {/* Pagination */}
<DataTablePagination table={table} />
-
- {/* Floating Bar (선택된 행 있을 때) */}
{table.getFilteredSelectedRowModel().rows.length > 0 && floatingBar}
</div>
</div>
diff --git a/components/data-table/infinite-data-table.tsx b/components/data-table/infinite-data-table.tsx
index fcac56ee..b8764d62 100644
--- a/components/data-table/infinite-data-table.tsx
+++ b/components/data-table/infinite-data-table.tsx
@@ -6,7 +6,7 @@ import { ChevronRight, ChevronUp, Loader2 } from "lucide-react"
import { useIntersection } from "@mantine/hooks"
import { cn } from "@/lib/utils"
-import { getCommonPinningStyles } from "@/lib/data-table"
+import { getCommonPinningStylesWithBorder } from "@/lib/data-table"
import {
Table,
TableBody,
@@ -53,6 +53,9 @@ export function InfiniteDataTable<TData>({
useAutoSizeColumns(table, autoSizeColumns)
+ // 🎯 스크롤 상태 감지 추가
+ const [isScrolled, setIsScrolled] = React.useState(false)
+
// Intersection Observer for infinite scroll
const { ref: loadMoreRef, entry } = useIntersection({
threshold: 0.1,
@@ -65,21 +68,62 @@ export function InfiniteDataTable<TData>({
}
}, [entry?.isIntersecting, hasNextPage, isLoadingMore, onLoadMore])
+ // 🎯 스크롤 핸들러 추가
+ const handleScroll = (e: React.UIEvent<HTMLDivElement>) => {
+ const scrollLeft = e.currentTarget.scrollLeft
+ setIsScrolled(scrollLeft > 0)
+ }
+
+ // 🎯 동적 핀 스타일 함수 (width 중복 제거)
+ const getPinnedStyle = (column: any, isHeader: boolean = false) => {
+ const baseStyle = getCommonPinningStylesWithBorder({ column })
+ const pinnedSide = column.getIsPinned()
+
+ // width를 제외한 나머지 스타일만 반환
+ const { width, ...restBaseStyle } = baseStyle
+
+ return {
+ ...restBaseStyle,
+ // 헤더는 핀 여부와 관계없이 항상 배경 유지 (sticky로 고정되어 있기 때문)
+ ...(isHeader && {
+ background: "hsl(var(--background))",
+ transition: "none",
+ }),
+ // 바디 셀은 핀된 경우에만 스크롤 상태에 따라 동적 변경
+ ...(!isHeader && pinnedSide && {
+ background: isScrolled
+ ? "hsl(var(--background))"
+ : "transparent",
+ transition: "background-color 0.15s ease-out",
+ }),
+ }
+ }
+
+ // 🎯 테이블 총 너비 계산
+ const getTableWidth = React.useCallback(() => {
+ const totalSize = table.getCenterTotalSize() + table.getLeftTotalSize() + table.getRightTotalSize()
+ return Math.max(totalSize, 800) // 최소 800px 보장
+ }, [table])
+
// 컴팩트 모드를 위한 클래스 정의
const compactStyles = compact ? {
row: "h-7",
cell: "py-1 px-2 text-sm",
+ header: "py-1 px-2 text-sm", // 헤더 스타일 추가
+ headerRow: "h-8", // 헤더 행 높이 추가
groupRow: "py-1 bg-muted/20 text-sm",
emptyRow: "h-16",
} : {
row: "",
cell: "",
+ header: "", // 헤더 스타일 추가
+ headerRow: "", // 헤더 행 높이 추가
groupRow: "bg-muted/20",
emptyRow: "h-24",
}
return (
- <div className={cn("w-full space-y-2.5 overflow-auto", className)} {...props}>
+ <div className={cn("w-full space-y-2.5", className)} {...props}>
{children}
{/* 총 개수 표시 */}
@@ -97,12 +141,19 @@ export function InfiniteDataTable<TData>({
<div
className="max-w-[100vw] overflow-auto"
style={{ maxHeight: maxHeight || '35rem' }}
+ onScroll={handleScroll} // 🎯 스크롤 이벤트 핸들러 추가
>
- <Table className="[&>thead]:sticky [&>thead]:top-0 [&>thead]:z-10 table-fixed">
+ <Table
+ className="[&>thead]:sticky [&>thead]:top-0 [&>thead]:z-10"
+ style={{
+ width: getTableWidth(), // 🎯 동적 너비 계산
+ minWidth: '100%'
+ }}
+ >
{/* 테이블 헤더 */}
<TableHeader>
{table.getHeaderGroups().map((headerGroup) => (
- <TableRow key={headerGroup.id} className={compact ? "h-8" : ""}>
+ <TableRow key={headerGroup.id} className={compactStyles.headerRow}>
{headerGroup.headers.map((header) => {
if (header.column.getIsGrouped()) {
return null
@@ -113,10 +164,10 @@ export function InfiniteDataTable<TData>({
key={header.id}
colSpan={header.colSpan}
data-column-id={header.column.id}
- className={compact ? "py-1 px-2 text-sm" : ""}
+ className={compactStyles.header}
style={{
- ...getCommonPinningStyles({ column: header.column }),
- width: header.getSize(),
+ ...getPinnedStyle(header.column, true), // 🎯 헤더임을 명시
+ width: header.getSize(), // 🎯 width 별도 설정
}}
>
<div style={{ position: "relative" }}>
@@ -211,8 +262,8 @@ export function InfiniteDataTable<TData>({
data-column-id={cell.column.id}
className={compactStyles.cell}
style={{
- ...getCommonPinningStyles({ column: cell.column }),
- width: cell.column.getSize(),
+ ...getPinnedStyle(cell.column, false), // 🎯 바디 셀임을 명시
+ width: cell.column.getSize(), // 🎯 width 별도 설정
}}
>
{flexRender(
diff --git a/components/data-table/view-mode-toggle.tsx b/components/data-table/view-mode-toggle.tsx
new file mode 100644
index 00000000..fc07b4bf
--- /dev/null
+++ b/components/data-table/view-mode-toggle.tsx
@@ -0,0 +1,91 @@
+"use client"
+
+import * as React from "react"
+import { Button } from "@/components/ui/button"
+import { Badge } from "@/components/ui/badge"
+import { Infinity, Grid3X3, RotateCcw } from "lucide-react"
+import {
+ Tooltip,
+ TooltipContent,
+ TooltipProvider,
+ TooltipTrigger,
+} from "@/components/ui/tooltip"
+
+interface ViewModeToggleProps {
+ isInfiniteMode: boolean
+ onSwitch: (pageSize: number) => void
+ className?: string
+}
+
+export function ViewModeToggle({ isInfiniteMode, onSwitch, className }: ViewModeToggleProps) {
+
+ const switchToPagination = React.useCallback(() => {
+ onSwitch(10)
+ }, [onSwitch])
+
+
+
+ // 새로고침
+ const handleRefresh = React.useCallback(() => {
+ window.location.reload()
+ }, [])
+
+ return (
+ <div className={`flex items-center gap-2 ${className}`}>
+ {/* 현재 모드 표시 */}
+ <div className="flex items-center gap-2">
+ {isInfiniteMode && (
+ <Badge variant="default" className="bg-blue-100 text-blue-800 hover:bg-blue-200">
+ <Infinity className="h-3 w-3 mr-1" />
+ 무한 스크롤 모드
+ </Badge>
+ ) }
+ </div>
+
+ {/* 모드 전환 버튼 */}
+ <div className="flex items-center gap-1">
+ {isInfiniteMode && (
+ <TooltipProvider>
+ <Tooltip>
+ <TooltipTrigger asChild>
+ <Button
+ variant="outline"
+ size="sm"
+ onClick={switchToPagination}
+ className="h-8 px-2"
+ >
+ <Grid3X3 className="h-4 w-4 mr-1" />
+ 페이지 모드
+ </Button>
+ </TooltipTrigger>
+ <TooltipContent>
+ <p>페이지네이션 모드로 전환 (20개씩 표시)</p>
+ </TooltipContent>
+ </Tooltip>
+ </TooltipProvider>
+ ) }
+
+ {/* 새로고침 버튼 */}
+ {isInfiniteMode && (
+ <TooltipProvider>
+ <Tooltip>
+ <TooltipTrigger asChild>
+ <Button
+ variant="ghost"
+ size="sm"
+ onClick={handleRefresh}
+ className="h-8 w-8 p-0"
+ >
+ <RotateCcw className="h-4 w-4" />
+ </Button>
+ </TooltipTrigger>
+ <TooltipContent>
+ <p>새로고침</p>
+ </TooltipContent>
+ </Tooltip>
+ </TooltipProvider>
+ )}
+ </div>
+ </div>
+ )
+} \ No newline at end of file