From 37611339fea096e47aaa42311a13a6313b4200db Mon Sep 17 00:00:00 2001
From: dujinkim <dujin.kim@dtsolution.co.kr>
Date: Mon, 2 Jun 2025 02:27:28 +0000
Subject: (대표님) 20250602 오전 작업사항 (코드프리징)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

---
 components/data-table/expandable-data-table.tsx | 226 +++++++++++++++---------
 1 file changed, 138 insertions(+), 88 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 112e9448..15c59540 100644
--- a/components/data-table/expandable-data-table.tsx
+++ b/components/data-table/expandable-data-table.tsx
@@ -29,8 +29,11 @@ interface ExpandableDataTableProps<TData> extends React.HTMLAttributes<HTMLDivEl
   expandable?: boolean
   maxHeight?: string | number
   expandedRowClassName?: string
-  debug?: boolean // 디버깅 옵션 추가
-  simpleExpansion?: boolean // 🎯 간단한 확장 방식 선택 옵션 추가
+  debug?: boolean
+  simpleExpansion?: boolean
+  // ✅ 새로운 props 추가
+  clickableColumns?: string[] // 클릭 가능한 컬럼 ID들
+  excludeFromClick?: string[] // 클릭에서 제외할 컬럼 ID들
 }
 
 export function ExpandableDataTable<TData>({
@@ -45,7 +48,10 @@ export function ExpandableDataTable<TData>({
   maxHeight,
   expandedRowClassName,
   debug = false,
-  simpleExpansion = false, // 🎯 기본값 false (전체 확장 방식)
+  simpleExpansion = false,
+  // ✅ 새로운 props
+  clickableColumns,
+  excludeFromClick = ['actions'],
   children,
   className,
   ...props
@@ -54,20 +60,19 @@ export function ExpandableDataTable<TData>({
   useAutoSizeColumns(table, autoSizeColumns)
 
   const containerRef = React.useRef<HTMLDivElement>(null)
-  const scrollRef = React.useRef<HTMLDivElement>(null) // 🎯 스크롤 컨테이너 ref 추가
+  const scrollRef = React.useRef<HTMLDivElement>(null)
   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 [isScrolledToEnd, setIsScrolledToEnd] = React.useState(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)
       }
     }
@@ -75,13 +80,12 @@ export function ExpandableDataTable<TData>({
     const resizeObserver = new ResizeObserver(updateContainerWidth)
     resizeObserver.observe(containerRef.current)
     
-    // 초기 너비 설정
     updateContainerWidth()
 
     return () => resizeObserver.disconnect()
   }, [])
 
-  // 🎯 초기 스크롤 상태 체크 (더 확실한 초기화)
+  // 초기 스크롤 상태 체크
   React.useEffect(() => {
     if (!scrollRef.current) return
     
@@ -108,15 +112,12 @@ export function ExpandableDataTable<TData>({
       }
     }
     
-    // 즉시 체크
     checkScrollState()
     
-    // 짧은 지연 후 재체크 (테이블 렌더링 완료 대기)
     const timeouts = [50, 100, 200].map(delay => 
       setTimeout(checkScrollState, delay)
     )
     
-    // ResizeObserver로 테이블 크기 변화 감지
     const resizeObserver = new ResizeObserver(() => {
       setTimeout(checkScrollState, 10)
     })
@@ -128,24 +129,28 @@ export function ExpandableDataTable<TData>({
     }
   }, [table, debug])
 
-  // 🎯 데이터 변경 시 스크롤 상태 재설정
+  // 데이터 변경 시 스크롤 상태 재설정
   React.useEffect(() => {
-    setIsInitialized(false) // 🎯 데이터 변경 시 초기화 상태 리셋
-    setIsScrolledToEnd(true) // 🎯 안전한 기본값으로 리셋
-  }, [table.getRowModel().rows.length])
+    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<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 오차 허용
+    const isAtEnd = Math.abs(scrollWidth - clientWidth - scrollLeft) < 1
     setIsScrolledToEnd(isAtEnd)
-    setIsInitialized(true) // 🎯 스크롤 이벤트 발생 시 초기화 완료
+    setIsInitialized(true)
     
     if (debug) {
       console.log('Scroll state:', {
@@ -159,7 +164,77 @@ export function ExpandableDataTable<TData>({
     }
   }
 
-  // 🔧 개선된 핀 스타일 함수 (좌/우 핀 구분 처리)
+  // 행 확장/축소 핸들러
+  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)
@@ -174,9 +249,7 @@ export function ExpandableDataTable<TData>({
       const pinnedSide = column.getIsPinned()
       
       if (!pinnedSide) {
-        // width를 제외한 나머지 스타일만 반환
         const { width, ...restStyle } = baseStyle
-        // 헤더인 경우 핀되지 않았어도 배경 필요 (sticky 때문에)
         return {
           ...restStyle,
           ...(isHeader && {
@@ -186,10 +259,9 @@ export function ExpandableDataTable<TData>({
         }
       }
 
-      // 확장 버튼이 있을 때 left pin된 컬럼들을 오른쪽으로 이동
       let leftPosition = baseStyle.left
       if (expandable && pinnedSide === "left") {
-        const expandButtonWidth = 40 // w-10 = 40px
+        const expandButtonWidth = 40
         if (typeof baseStyle.left === 'string') {
           const currentLeft = parseFloat(baseStyle.left.replace('px', ''))
           leftPosition = `${currentLeft + expandButtonWidth}px`
@@ -200,23 +272,17 @@ export function ExpandableDataTable<TData>({
         }
       }
       
-      // 🎯 핀 위치에 따른 배경 결정
       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 = {
@@ -244,7 +310,6 @@ export function ExpandableDataTable<TData>({
       return finalStyle
     } catch (error) {
       console.error("Error in getPinnedStyle:", error)
-      // fallback 스타일
       return {
         position: 'relative' as const,
         ...(isHeader && {
@@ -254,7 +319,7 @@ export function ExpandableDataTable<TData>({
     }
   }, [expandable, isScrolled, isScrolledToEnd, isInitialized, debug])
 
-  // 확장 버튼용 스타일 (안정성 개선)
+  // 확장 버튼용 스타일
   const getExpandButtonStyle = React.useCallback(() => {
     return {
       position: 'sticky' as const,
@@ -267,50 +332,19 @@ export function ExpandableDataTable<TData>({
     }
   }, [])
 
-  // 🎯 테이블 총 너비 계산
+  // 테이블 총 너비 계산
   const getTableWidth = React.useCallback(() => {
     const expandButtonWidth = expandable ? 40 : 0
     const totalSize = table.getCenterTotalSize() + table.getLeftTotalSize() + table.getRightTotalSize()
-    return Math.max(totalSize + expandButtonWidth, 800) // 최소 800px 보장
+    return Math.max(totalSize + expandButtonWidth, 800)
   }, [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) 제외
+    const availableWidth = containerWidth || 800
+    const contentWidth = availableWidth - expandButtonWidth - 32
     
-    // 🎯 디버그 정보
     if (debug) {
       console.log('Expanded content sizing:', {
         containerWidth,
@@ -322,8 +356,8 @@ export function ExpandableDataTable<TData>({
     }
     
     return {
-      width: `${Math.max(contentWidth, 300)}px`, // 🎯 최소 300px 보장
-      marginLeft: `${expandButtonWidth + 8}px`, // 🎯 확장 버튼 + 여백
+      width: `${Math.max(contentWidth, 300)}px`,
+      marginLeft: `${expandButtonWidth + 8}px`,
       marginRight: '16px',
       padding: '12px 16px',
       backgroundColor: 'hsl(var(--background))',
@@ -332,16 +366,16 @@ export function ExpandableDataTable<TData>({
       border: '1px solid hsl(var(--border))',
       borderTop: 'none',
       marginBottom: '4px',
-      maxWidth: `${availableWidth - expandButtonWidth - 16}px`, // 🎯 컨테이너 너비 초과 방지
+      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 // 버튼 + 여백 제외
+    const contentWidth = availableWidth - expandButtonWidth - 48
     
     return {
       marginLeft: `${expandButtonWidth + 8}px`,
@@ -358,12 +392,12 @@ export function ExpandableDataTable<TData>({
     }
   }, [expandable, containerWidth])
 
-  // 🎯 사용할 확장 스타일 선택 (props로 제어)
   const useSimpleExpansion = simpleExpansion
   const getExpandedPlaceholderHeight = React.useCallback((contentHeight: number) => {
-    const padding = 24 + 4 // padding (12px * 2) + marginBottom (4px)
+    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) {
@@ -453,22 +487,22 @@ export function ExpandableDataTable<TData>({
       {children}
       
       <div 
-        ref={containerRef} // 🎯 컨테이너 wrapper ref (패딩 제외 너비 계산용)
+        ref={containerRef}
         className="relative rounded-md border"
         style={{ 
           minHeight: '200px'
         }}
       >
         <div 
-          ref={scrollRef} // 🎯 스크롤 컨테이너 ref 연결
+          ref={scrollRef}
           className="overflow-auto"
           style={{ maxHeight: maxHeight || '34rem' }}
-          onScroll={handleScroll} // 🎯 스크롤 이벤트 핸들러
+          onScroll={handleScroll}
         >
           <Table 
             className="[&>thead]:sticky [&>thead]:top-0 [&>thead]:z-10"
             style={{ 
-              width: getTableWidth(), // 🎯 동적 너비 계산
+              width: getTableWidth(),
               minWidth: '100%'
             }}
           >
@@ -497,8 +531,8 @@ export function ExpandableDataTable<TData>({
                           "bg-background"
                         )}
                         style={{
-                          ...getPinnedStyle(header.column, true), // 🎯 동적 스타일
-                          width: header.getSize() // 🎯 width 별도 설정
+                          ...getPinnedStyle(header.column, true),
+                          width: header.getSize()
                         }}
                       >
                         <div style={{ position: "relative" }}>
@@ -599,15 +633,33 @@ export function ExpandableDataTable<TData>({
                             return null
                           }
 
+                          const isClickable = isCellClickable(cell.column.id)
+
                           return (
                             <TableCell
                               key={cell.id}
                               data-column-id={cell.column.id}
-                              className={compactStyles.cell}
+                              className={cn(
+                                compactStyles.cell,
+                                // ✅ 클릭 가능한 셀에 시각적 피드백 추가
+                                isClickable && "cursor-pointer hover:bg-muted/50 transition-colors"
+                              )}
                               style={{
-                                ...getPinnedStyle(cell.column, false), // 🎯 동적 스타일
-                                width: cell.column.getSize() // 🎯 width 별도 설정
+                                ...getPinnedStyle(cell.column, false),
+                                width: cell.column.getSize()
                               }}
+                              // ✅ 클릭 이벤트 추가
+                              onClick={isClickable ? (e) => 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,
@@ -632,14 +684,12 @@ export function ExpandableDataTable<TData>({
                             }}
                           >
                             {useSimpleExpansion ? (
-                              // 🎯 간단한 확장 방식: 테이블 내부에서만 확장
                               <div style={getSimpleExpandedStyle()}>
                                 <ExpandedContentWrapper rowId={row.id}>
                                   {renderExpandedContent(row.original)}
                                 </ExpandedContentWrapper>
                               </div>
                             ) : (
-                              // 🎯 전체 화면 확장 방식: 테이블 너비 기준으로 개선
                               <div className="relative w-full">
                                 <div 
                                   className="absolute top-0"
-- 
cgit v1.2.3

