"use client" import * as React from "react" import { flexRender, type Table as TanstackTable } from "@tanstack/react-table" import { ChevronRight, ChevronUp, ChevronDown } from "lucide-react" import { cn } from "@/lib/utils" import { getCommonPinningStylesWithBorder, debugPinningInfo } from "@/lib/data-table" import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow, } from "@/components/ui/table" import { DataTablePagination } from "@/components/data-table/data-table-pagination" import { DataTableResizer } from "@/components/data-table/data-table-resizer" import { useAutoSizeColumns } from "@/hooks/useAutoSizeColumns" interface ExpandableDataTableProps extends React.HTMLAttributes { table: TanstackTable floatingBar?: React.ReactNode | null autoSizeColumns?: boolean compact?: boolean expandedRows?: Set setExpandedRows?: React.Dispatch>> renderExpandedContent?: (row: TData) => React.ReactNode expandable?: boolean maxHeight?: string | number expandedRowClassName?: string debug?: boolean // 디버깅 옵션 추가 simpleExpansion?: boolean // 🎯 간단한 확장 방식 선택 옵션 추가 } export function ExpandableDataTable({ table, floatingBar = null, autoSizeColumns = true, compact = false, expandedRows = new Set(), setExpandedRows, renderExpandedContent, expandable = false, maxHeight, expandedRowClassName, debug = false, simpleExpansion = false, // 🎯 기본값 false (전체 확장 방식) children, className, ...props }: ExpandableDataTableProps) { useAutoSizeColumns(table, autoSizeColumns) const containerRef = React.useRef(null) const scrollRef = React.useRef(null) // 🎯 스크롤 컨테이너 ref 추가 const [expandedHeights, setExpandedHeights] = React.useState>(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(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) => { 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 if (event) { event.stopPropagation() } const newExpanded = new Set(expandedRows) if (newExpanded.has(rowId)) { newExpanded.delete(rowId) setExpandedHeights(prev => { const newHeights = new Map(prev) newHeights.delete(rowId) return newHeights }) } else { newExpanded.add(rowId) } setExpandedRows(newExpanded) }, [expandedRows, setExpandedRows]) // 키보드 네비게이션 핸들러 const handleKeyDown = React.useCallback((event: React.KeyboardEvent, rowId: string) => { if (event.key === 'Enter' || event.key === ' ') { event.preventDefault() toggleRowExpansion(rowId) } }, [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) { const newHeights = new Map(prev) newHeights.set(rowId, height) return newHeights } return prev }) }, []) // 컴팩트 모드 스타일 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: "", header: "", headerRow: "", expandedCell: "py-0 px-0", groupRow: "bg-muted/20", emptyRow: "h-24", } // 확장 버튼 렌더링 const renderExpandButton = (rowId: string) => { if (!expandable || !setExpandedRows) return null const isExpanded = expandedRows.has(rowId) return ( ) } // 확장된 내용 래퍼 컴포넌트 const ExpandedContentWrapper = React.memo<{ rowId: string children: React.ReactNode }>(({ rowId, children }) => { const contentRef = React.useRef(null) React.useEffect(() => { if (contentRef.current) { const resizeObserver = new ResizeObserver((entries) => { for (const entry of entries) { updateExpandedHeight(rowId, entry.contentRect.height) } }) resizeObserver.observe(contentRef.current) return () => resizeObserver.disconnect() } }, [rowId]) return (
{children}
) }) ExpandedContentWrapper.displayName = "ExpandedContentWrapper" return (
{children}
{table.getHeaderGroups().map((headerGroup) => ( {expandable && ( )} {headerGroup.headers.map((header) => { if (header.column.getIsGrouped()) { return null } return (
{header.isPlaceholder ? null : flexRender( header.column.columnDef.header, header.getContext() )} {header.column.getCanResize() && ( )}
) })}
))}
{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 } } return ( {row.getCanExpand() && ( )} {columnLabel}: {row.getValue(groupingColumnId)} ({row.subRows.length} rows) ) } return ( {expandable && ( {renderExpandButton(row.id)} )} {row.getVisibleCells().map((cell) => { if (cell.column.getIsGrouped()) { return null } return ( {flexRender( cell.column.columnDef.cell, cell.getContext() )} ) })} {isExpanded && renderExpandedContent && ( {useSimpleExpansion ? ( // 🎯 간단한 확장 방식: 테이블 내부에서만 확장
{renderExpandedContent(row.original)}
) : ( // 🎯 전체 화면 확장 방식: 테이블 너비 기준으로 개선
{renderExpandedContent(row.original)}
)} )} ) }) ) : ( No results. )}
{table.getFilteredSelectedRowModel().rows.length > 0 && floatingBar}
) }