From bac0228d21b7195065e9cddcc327ae33659c7bcc Mon Sep 17 00:00:00 2001 From: dujinkim Date: Sun, 1 Jun 2025 13:52:21 +0000 Subject: (대표님) 20250601까지 작업사항 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- components/data-table/expandable-data-table.tsx | 707 ++++++++++++++++-------- 1 file changed, 486 insertions(+), 221 deletions(-) (limited to 'components/data-table/expandable-data-table.tsx') 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 extends React.HTMLAttributes setExpandedRows?: React.Dispatch>> renderExpandedContent?: (row: TData) => React.ReactNode - expandable?: boolean // 확장 기능 활성화 여부 + expandable?: boolean maxHeight?: string | number - expandedRowClassName?: string // 확장된 행의 커스텀 클래스 + expandedRowClassName?: string + debug?: boolean // 디버깅 옵션 추가 + simpleExpansion?: boolean // 🎯 간단한 확장 방식 선택 옵션 추가 } -/** - * 확장 가능한 데이터 테이블 - 행 확장 시 바로 아래에 컨텐츠 표시 - * 개선사항: - * - 가로스크롤과 확장된 내용 독립성 보장 - * - 동적 높이 계산으로 세로스크롤 문제 해결 - * - 향상된 접근성 및 키보드 네비게이션 - */ export function ExpandableDataTable({ table, floatingBar = null, @@ -49,6 +44,8 @@ export function ExpandableDataTable({ expandable = false, maxHeight, expandedRowClassName, + debug = false, + simpleExpansion = false, // 🎯 기본값 false (전체 확장 방식) children, className, ...props @@ -56,11 +53,228 @@ export function ExpandableDataTable({ useAutoSizeColumns(table, autoSizeColumns) - // 스크롤 컨테이너 참조 - const scrollContainerRef = React.useRef(null) + 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 @@ -71,7 +285,6 @@ export function ExpandableDataTable({ 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({ } }, [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({ }) }, []) - // 컴팩트 모드를 위한 클래스 정의 (개선된 버전) + // 컴팩트 모드 스타일 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({ ) } - // 확장된 내용 래퍼 컴포넌트 (높이 측정 기능 포함) + // 확장된 내용 래퍼 컴포넌트 const ExpandedContentWrapper = React.memo<{ rowId: string children: React.ReactNode @@ -176,244 +452,233 @@ export function ExpandableDataTable({
{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() - )} +
+
+ + {table.getHeaderGroups().map((headerGroup) => ( + + {expandable && ( + + )} + + {headerGroup.headers.map((header) => { + if (header.column.getIsGrouped()) { + return null + } - {header.column.getCanResize() && ( - + return ( + +
+ {header.isPlaceholder + ? null + : flexRender( + header.column.columnDef.header, + header.getContext() )} -
-
- ) - })} -
- ))} -
- - {/* 테이블 바디 */} - - {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 ( - - - {row.getCanExpand() && ( - - )} + {header.column.getCanResize() && ( + + )} + + + ) + })} + + ))} + - - {columnLabel}: {row.getValue(groupingColumnId)} - - - ({row.subRows.length} rows) - - - - ) + + {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 ( - - {/* 메인 데이터 행 */} - + - {/* 확장 버튼 셀 */} - {expandable && ( - - {renderExpandButton(row.id)} - + {row.getIsExpanded() ? ( + + ) : ( + + )} + )} - {/* 데이터 셀들 */} - {row.getVisibleCells().map((cell) => { - if (cell.column.getIsGrouped()) { - return null - } - - return ( - - {flexRender( - cell.column.columnDef.cell, - cell.getContext() - )} - - ) - })} - + + {columnLabel}: {row.getValue(groupingColumnId)} + + + ({row.subRows.length} rows) + + + + ) + } + + return ( + + + {expandable && ( + + {renderExpandButton(row.id)} + + )} - {/* 확장된 컨텐츠 행 - 가로스크롤 독립성 보장 */} - {isExpanded && renderExpandedContent && ( - + {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)} - -
+ + {renderExpandedContent(row.original)} +
- {/* 높이 유지를 위한 스페이서 */}
- - - )} - - ) - }) - ) : ( - // 데이터가 없을 때 - - - No results. - - - )} - -
-
+ )} + + + )} + + ) + }) + ) : ( + + + No results. + + + )} + +
- {/* Pagination */} - - {/* Floating Bar (선택된 행 있을 때) */} {table.getFilteredSelectedRowModel().rows.length > 0 && floatingBar}
-- cgit v1.2.3