"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 // ✅ 새로운 props 추가 clickableColumns?: string[] // 클릭 가능한 컬럼 ID들 excludeFromClick?: string[] // 클릭에서 제외할 컬럼 ID들 } export function ExpandableDataTable({ table, floatingBar = null, autoSizeColumns = true, compact = false, expandedRows = new Set(), setExpandedRows, renderExpandedContent, expandable = false, maxHeight, expandedRowClassName, debug = false, simpleExpansion = false, // ✅ 새로운 props clickableColumns, excludeFromClick = ['actions'], children, className, ...props }: ExpandableDataTableProps) { useAutoSizeColumns(table, autoSizeColumns) // nested header 감지: columns 속성을 가진 헤더가 있는지 확인 const hasNestedHeader = React.useMemo(() => { return table.getHeaderGroups().some(headerGroup => headerGroup.headers.some(header => 'columns' in header.column.columnDef) ) }, [table]) const containerRef = React.useRef(null) const scrollRef = React.useRef(null) const [expandedHeights, setExpandedHeights] = React.useState>(new Map()) const [isScrolled, setIsScrolled] = React.useState(false) const [isScrolledToEnd, setIsScrolledToEnd] = React.useState(true) const [isInitialized, setIsInitialized] = React.useState(false) const [containerWidth, setContainerWidth] = React.useState(0) // 컨테이너 너비 감지 React.useEffect(() => { if (!containerRef.current) return const updateContainerWidth = () => { if (containerRef.current) { 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) ) 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) if (debug) { const allColumns = table.getAllColumns().map(col => col.id) const visibleColumns = table.getVisibleFlatColumns().map(col => col.id) console.log('🔍 Available columns:', { allColumns, visibleColumns, clickableColumns, excludeFromClick }) } }, [table.getRowModel().rows.length, clickableColumns, excludeFromClick, debug]) 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 setIsScrolledToEnd(isAtEnd) setIsInitialized(true) if (debug) { console.log('Scroll state:', { scrollLeft, scrollWidth, clientWidth, isScrolled: scrollLeft > 0, isScrolledToEnd: isAtEnd, remainingScroll: scrollWidth - clientWidth - scrollLeft }) } } // 행 확장/축소 핸들러 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 isCellClickable = React.useCallback((columnId: string) => { if (!expandable || !setExpandedRows) return false if (excludeFromClick.includes(columnId)) { if (debug) console.log('🚫 Column excluded:', columnId) return false } if (clickableColumns && clickableColumns.length > 0) { const isClickable = clickableColumns.includes(columnId) if (debug) console.log('📋 Column clickable check:', { columnId, clickableColumns, isClickable }) return isClickable } if (debug) console.log('✅ Column clickable by default:', columnId) return true }, [expandable, setExpandedRows, clickableColumns, excludeFromClick, debug]) // ✅ 셀 클릭 핸들러 const handleCellClick = React.useCallback((rowId: string, columnId: string, event: React.MouseEvent) => { if (debug) console.log('🔍 Cell clicked:', { rowId, columnId, clickable: isCellClickable(columnId) }) if (!isCellClickable(columnId)) { if (debug) console.log('❌ Cell not clickable:', columnId) return } const target = event.target as HTMLElement // 실제 BUTTON과 A 태그만 제외 if (target.tagName === 'BUTTON' || target.tagName === 'A') { if (debug) console.log('❌ Button or link clicked, ignoring') return } if (debug) console.log('✅ Toggling row expansion for:', rowId) toggleRowExpansion(rowId, event) }, [isCellClickable, toggleRowExpansion, debug]) // 키보드 네비게이션 핸들러 const handleKeyDown = React.useCallback((event: React.KeyboardEvent, rowId: string) => { if (event.key === 'Enter' || event.key === ' ') { event.preventDefault() toggleRowExpansion(rowId) } }, [toggleRowExpansion]) // 핀 스타일 함수 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) { const { width, ...restStyle } = baseStyle return { ...restStyle, ...(isHeader && { background: "hsl(var(--background))", transition: "none", }), } } let leftPosition = baseStyle.left if (expandable && pinnedSide === "left") { const expandButtonWidth = 40 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 } } 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) 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) }, [table, expandable]) // 확장된 내용 스타일 계산 const getExpandedContentStyle = React.useCallback(() => { const expandButtonWidth = expandable ? 40 : 0 const availableWidth = containerWidth || 800 const contentWidth = availableWidth - expandButtonWidth - 32 if (debug) { console.log('Expanded content sizing:', { containerWidth, availableWidth, expandButtonWidth, contentWidth, finalWidth: Math.max(contentWidth, 300) }) } return { width: `${Math.max(contentWidth, 300)}px`, 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]) const useSimpleExpansion = simpleExpansion const getExpandedPlaceholderHeight = React.useCallback((contentHeight: number) => { const padding = 24 + 4 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}
thead]:sticky [&>thead]:top-0 [&>thead]:z-10", !hasNestedHeader && "table-fixed" // nested header가 없으면 table-fixed 적용 )}> {/* nested header가 있으면 table-fixed 제거, 없으면 적용 */} {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() && !('columns' in header.column.columnDef) && ( )}
) })}
))}
{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 } const isClickable = isCellClickable(cell.column.id) return ( handleCellClick(row.id, cell.column.id, e) : undefined} // ✅ 접근성 개선 role={isClickable ? "button" : undefined} tabIndex={isClickable ? 0 : undefined} onKeyDown={isClickable ? (e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault() handleCellClick(row.id, cell.column.id, e as any) } } : undefined} aria-label={isClickable ? `${isExpanded ? '행 축소' : '행 확장'} - ${cell.column.id}` : undefined} > {flexRender( cell.column.columnDef.cell, cell.getContext() )} ) })} {isExpanded && renderExpandedContent && ( {useSimpleExpansion ? (
{renderExpandedContent(row.original)}
) : (
{renderExpandedContent(row.original)}
)} )} ) }) ) : ( No results. )}
{table.getFilteredSelectedRowModel().rows.length > 0 && floatingBar}
) }