diff options
Diffstat (limited to 'components')
| -rw-r--r-- | components/client-data-table/data-table-column-simple-header.tsx | 2 | ||||
| -rw-r--r-- | components/client-data-table/data-table.tsx | 74 | ||||
| -rw-r--r-- | components/data-table/data-table-pagination.tsx | 219 | ||||
| -rw-r--r-- | components/data-table/data-table-pin-right.tsx | 2 | ||||
| -rw-r--r-- | components/data-table/data-table.tsx | 6 | ||||
| -rw-r--r-- | components/data-table/expandable-data-table.tsx | 707 | ||||
| -rw-r--r-- | components/data-table/infinite-data-table.tsx | 69 | ||||
| -rw-r--r-- | components/data-table/view-mode-toggle.tsx | 91 | ||||
| -rw-r--r-- | components/form-data/delete-form-data-dialog.tsx | 217 | ||||
| -rw-r--r-- | components/form-data/export-excel-form.tsx | 97 | ||||
| -rw-r--r-- | components/form-data/form-data-table-columns.tsx | 50 | ||||
| -rw-r--r-- | components/form-data/form-data-table.tsx | 265 | ||||
| -rw-r--r-- | components/form-data/import-excel-form.tsx | 49 | ||||
| -rw-r--r-- | components/form-data/update-form-sheet.tsx | 102 | ||||
| -rw-r--r-- | components/layout/sidebar-nav.tsx | 1 |
15 files changed, 1355 insertions, 596 deletions
diff --git a/components/client-data-table/data-table-column-simple-header.tsx b/components/client-data-table/data-table-column-simple-header.tsx index 0f3997c6..9c4e1c8f 100644 --- a/components/client-data-table/data-table-column-simple-header.tsx +++ b/components/client-data-table/data-table-column-simple-header.tsx @@ -50,7 +50,7 @@ export function ClientDataTableColumnHeaderSimple<TData, TValue>({ <div onClick={handleClick} className={cn( - "flex cursor-pointer select-none items-center gap-1", + "flex cursor-pointer select-none items-center justify-between w-full", className )} > diff --git a/components/client-data-table/data-table.tsx b/components/client-data-table/data-table.tsx index b7851bb8..675fe192 100644 --- a/components/client-data-table/data-table.tsx +++ b/components/client-data-table/data-table.tsx @@ -27,7 +27,7 @@ import { TableHeader, TableRow, } from "@/components/ui/table" -import { getCommonPinningStyles } from "@/lib/data-table" +import { getCommonPinningStylesWithBorder } from "@/lib/data-table" import { ChevronRight, ChevronUp } from "lucide-react" import { ClientDataTableAdvancedToolbar } from "./data-table-toolbar" @@ -45,6 +45,8 @@ interface DataTableProps<TData, TValue> { maxHeight?: string | number /** 추가로 표시할 버튼/컴포넌트 */ children?: React.ReactNode + /** 선택 상태 초기화 트리거 */ + clearSelection?: boolean } export function ClientDataTable<TData, TValue>({ @@ -55,7 +57,8 @@ export function ClientDataTable<TData, TValue>({ compact = true, // 기본값 true children, maxHeight, - onSelectedRowsChange + onSelectedRowsChange, + clearSelection }: DataTableProps<TData, TValue>) { // (1) React Table 상태 @@ -66,10 +69,13 @@ export function ClientDataTable<TData, TValue>({ const [grouping, setGrouping] = React.useState<string[]>([]) const [columnSizing, setColumnSizing] = React.useState<ColumnSizingState>({}) const [columnPinning, setColumnPinning] = React.useState<ColumnPinningState>({ - left: ["TAG_NO", "TAG_DESC"], + left: ["select","TAG_NO", "TAG_DESC", "status"], right: ["update", 'actions'], }) + // 🎯 스크롤 상태 감지 추가 + const [isScrolled, setIsScrolled] = React.useState(false) + const table = useReactTable({ data, columns, @@ -113,6 +119,48 @@ export function ClientDataTable<TData, TValue>({ onSelectedRowsChange(selectedRows) }, [rowSelection, table, onSelectedRowsChange]) + // clearSelection prop이 변경되면 선택 상태 초기화 + React.useEffect(() => { + setRowSelection({}) + }, [clearSelection]) + + // 🎯 스크롤 핸들러 추가 + 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", // 행 높이 축소 @@ -143,9 +191,17 @@ export function ClientDataTable<TData, TValue>({ </ClientDataTableAdvancedToolbar> <div className="rounded-md border"> - <div className="overflow-auto" style={{ maxHeight: maxHeight || '34rem' }} > + <div + className="overflow-auto" + style={{ maxHeight: maxHeight || '34rem' }} + onScroll={handleScroll} // 🎯 스크롤 이벤트 핸들러 추가 + > <UiTable - className="[&>thead]:sticky [&>thead]:top-0 [&>thead]:z-10 table-fixed" + className="[&>thead]:sticky [&>thead]:top-0 [&>thead]:z-10" + style={{ + width: getTableWidth(), // 🎯 동적 너비 계산 + minWidth: '100%' + }} > <TableHeader> {table.getHeaderGroups().map((headerGroup) => ( @@ -163,8 +219,8 @@ export function ClientDataTable<TData, TValue>({ data-column-id={header.column.id} className={compactStyles.header} style={{ - ...getCommonPinningStyles({ column: header.column }), - width: header.getSize() + ...getPinnedStyle(header.column, true), // 🎯 헤더임을 명시 + width: header.getSize() // 🎯 width 별도 설정 }} > <div style={{ position: "relative" }}> @@ -269,8 +325,8 @@ export function ClientDataTable<TData, TValue>({ 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/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 diff --git a/components/form-data/delete-form-data-dialog.tsx b/components/form-data/delete-form-data-dialog.tsx new file mode 100644 index 00000000..ca2f8729 --- /dev/null +++ b/components/form-data/delete-form-data-dialog.tsx @@ -0,0 +1,217 @@ +"use client" + +import * as React from "react" +import { Loader, Trash } from "lucide-react" +import { toast } from "sonner" + +import { useMediaQuery } from "@/hooks/use-media-query" +import { Button } from "@/components/ui/button" +import { + Dialog, + DialogClose, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, + DialogTrigger, +} from "@/components/ui/dialog" +import { + Drawer, + DrawerClose, + DrawerContent, + DrawerDescription, + DrawerFooter, + DrawerHeader, + DrawerTitle, + DrawerTrigger, +} from "@/components/ui/drawer" + +import { deleteFormDataByTags } from "@/lib/forms/services" + +interface GenericData { + [key: string]: any + TAG_NO?: string +} + +interface DeleteFormDataDialogProps + extends React.ComponentPropsWithoutRef<typeof Dialog> { + formData: GenericData[] + formCode: string + contractItemId: number + showTrigger?: boolean + onSuccess?: () => void + triggerVariant?: "default" | "destructive" | "outline" | "secondary" | "ghost" | "link" +} + +export function DeleteFormDataDialog({ + formData, + formCode, + contractItemId, + showTrigger = true, + onSuccess, + triggerVariant = "outline", + ...props +}: DeleteFormDataDialogProps) { + const [isDeletePending, startDeleteTransition] = React.useTransition() + const isDesktop = useMediaQuery("(min-width: 640px)") + + // TAG_NO가 있는 항목들만 필터링 + const validItems = formData.filter(item => item.TAG_NO?.trim()) + const tagNos = validItems.map(item => item.TAG_NO).filter(Boolean) as string[] + + function onDelete() { + startDeleteTransition(async () => { + if (tagNos.length === 0) { + toast.error("No valid items to delete") + return + } + + const result = await deleteFormDataByTags({ + formCode, + contractItemId, + tagNos, + }) + + if (result.error) { + toast.error(result.error) + return + } + + props.onOpenChange?.(false) + + // 성공 메시지 (개수는 같을 것으로 예상) + const deletedCount = result.deletedCount || 0 + const deletedTagsCount = result.deletedTagsCount || 0 + + if (deletedCount !== deletedTagsCount) { + // 데이터 불일치 경고 + console.warn(`Data inconsistency: FormEntries deleted: ${deletedCount}, Tags deleted: ${deletedTagsCount}`) + toast.error( + `Deleted ${deletedCount} form entries and ${deletedTagsCount} tags (data inconsistency detected)` + ) + } else { + // 정상적인 삭제 완료 + toast.success( + `Successfully deleted ${deletedCount} item${deletedCount === 1 ? "" : "s"}` + ) + } + + onSuccess?.() + }) + } + + const itemCount = tagNos.length + const hasValidItems = itemCount > 0 + + if (isDesktop) { + return ( + <Dialog {...props}> + {showTrigger ? ( + <DialogTrigger asChild> + <Button + variant={triggerVariant} + size="sm" + disabled={!hasValidItems} + > + <Trash className="mr-2 size-4" aria-hidden="true" /> + Delete ({itemCount}) + </Button> + </DialogTrigger> + ) : null} + <DialogContent> + <DialogHeader> + <DialogTitle>Are you absolutely sure?</DialogTitle> + <DialogDescription> + This action cannot be undone. This will permanently delete{" "} + <span className="font-medium">{itemCount}</span> + {itemCount === 1 ? " item" : " items"} and related tag records from the database. + {itemCount > 0 && ( + <> + <br /> + <br /> + <span className="text-sm text-muted-foreground"> + TAG_NO(s): {tagNos.slice(0, 3).join(", ")} + {tagNos.length > 3 && ` and ${tagNos.length - 3} more...`} + </span> + </> + )} + </DialogDescription> + </DialogHeader> + <DialogFooter className="gap-2 sm:space-x-0"> + <DialogClose asChild> + <Button variant="outline">Cancel</Button> + </DialogClose> + <Button + aria-label="Delete selected entries" + variant="destructive" + onClick={onDelete} + disabled={isDeletePending || !hasValidItems} + > + {isDeletePending && ( + <Loader + className="mr-2 size-4 animate-spin" + aria-hidden="true" + /> + )} + Delete + </Button> + </DialogFooter> + </DialogContent> + </Dialog> + ) + } + + return ( + <Drawer {...props}> + {showTrigger ? ( + <DrawerTrigger asChild> + <Button + variant={triggerVariant} + size="sm" + disabled={!hasValidItems} + > + <Trash className="mr-2 size-4" aria-hidden="true" /> + Delete ({itemCount}) + </Button> + </DrawerTrigger> + ) : null} + <DrawerContent> + <DrawerHeader> + <DrawerTitle>Are you absolutely sure?</DrawerTitle> + <DrawerDescription> + This action cannot be undone. This will permanently delete{" "} + <span className="font-medium">{itemCount}</span> + {itemCount === 1 ? " item" : " items"} and related tag records from the database. + {itemCount > 0 && ( + <> + <br /> + <br /> + <span className="text-sm text-muted-foreground"> + TAG_NO(s): {tagNos.slice(0, 3).join(", ")} + {tagNos.length > 3 && ` and ${tagNos.length - 3} more...`} + </span> + </> + )} + </DrawerDescription> + </DrawerHeader> + <DrawerFooter className="gap-2 sm:space-x-0"> + <DrawerClose asChild> + <Button variant="outline">Cancel</Button> + </DrawerClose> + <Button + aria-label="Delete selected entries" + variant="destructive" + onClick={onDelete} + disabled={isDeletePending || !hasValidItems} + > + {isDeletePending && ( + <Loader className="mr-2 size-4 animate-spin" aria-hidden="true" /> + )} + Delete + </Button> + </DrawerFooter> + </DrawerContent> + </Drawer> + ) +}
\ No newline at end of file diff --git a/components/form-data/export-excel-form.tsx b/components/form-data/export-excel-form.tsx index c4010df2..d0ccf980 100644 --- a/components/form-data/export-excel-form.tsx +++ b/components/form-data/export-excel-form.tsx @@ -12,6 +12,7 @@ export interface DataTableColumnJSON { label: string; type: ColumnType; options?: string[]; + shi?: boolean; // SHI-only field indicator // Add any other properties that might be in columnsJSON } @@ -98,29 +99,64 @@ export async function exportExcelData({ const headerRow = worksheet.getRow(1); headerRow.font = { bold: true }; headerRow.alignment = { horizontal: "center" }; - headerRow.eachCell((cell) => { - cell.fill = { - type: "pattern", - pattern: "solid", - fgColor: { argb: "FFCCCCCC" }, - }; + + // 각 헤더 셀에 스타일 적용 + headerRow.eachCell((cell, colNumber) => { + const columnIndex = colNumber - 1; + const column = columnsJSON[columnIndex]; + + if (column?.shi === true) { + // SHI-only 필드는 더 진한 음영으로 표시 (헤더 라벨은 원본 유지) + cell.fill = { + type: "pattern", + pattern: "solid", + fgColor: { argb: "FFFF9999" }, // 연한 빨간색 배경 + }; + cell.font = { bold: true, color: { argb: "FF800000" } }; // 진한 빨간색 글자 + } else { + // 일반 필드는 기존 스타일 + cell.fill = { + type: "pattern", + pattern: "solid", + fgColor: { argb: "FFCCCCCC" }, // 연한 회색 배경 + }; + } }); // 3. 데이터 행 추가 - tableData.forEach((row) => { + tableData.forEach((rowData, rowIndex) => { const rowValues = columnsJSON.map((col) => { - const value = row[col.key]; + const value = rowData[col.key]; return value !== undefined && value !== null ? value : ""; }); - worksheet.addRow(rowValues); + const dataRow = worksheet.addRow(rowValues); + + // SHI-only 컬럼의 데이터 셀에도 음영 적용 + dataRow.eachCell((cell, colNumber) => { + const columnIndex = colNumber - 1; + const column = columnsJSON[columnIndex]; + + if (column?.shi === true) { + // SHI-only 필드의 데이터 셀에 연한 음영 적용 + cell.fill = { + type: "pattern", + pattern: "solid", + fgColor: { argb: "FFFFCCCC" }, // 매우 연한 빨간색 배경 + }; + // 읽기 전용임을 나타내기 위해 이탤릭 적용 + cell.font = { italic: true, color: { argb: "FF666666" } }; + } + }); }); // 4. 데이터 유효성 검사 적용 const maxRows = 5000; // 데이터 유효성 검사를 적용할 최대 행 수 columnsJSON.forEach((col, idx) => { - if (col.type === "LIST" && validationRanges.has(col.key)) { - const colLetter = worksheet.getColumn(idx + 1).letter; + const colLetter = worksheet.getColumn(idx + 1).letter; + + // SHI-only 필드가 아닌 LIST 타입에만 유효성 검사 적용 + if (col.type === "LIST" && validationRanges.has(col.key) && col.shi !== true) { const validationRange = validationRanges.get(col.key)!; // 유효성 검사 정의 @@ -156,6 +192,19 @@ export async function exportExcelData({ } } } + + // SHI-only 필드의 빈 행들에도 음영 처리 적용 + if (col.shi === true) { + for (let rowIdx = tableData.length + 2; rowIdx <= maxRows; rowIdx++) { + const cell = worksheet.getCell(`${colLetter}${rowIdx}`); + cell.fill = { + type: "pattern", + pattern: "solid", + fgColor: { argb: "FFFFCCCC" }, + }; + cell.font = { italic: true, color: { argb: "FF666666" } }; + } + } }); // 5. 컬럼 너비 자동 조정 @@ -178,7 +227,31 @@ export async function exportExcelData({ column.width = Math.min(Math.max(maxLength + 2, 10), 50); }); - // 6. 파일 다운로드 + // 6. 범례 추가 (별도 시트) + const legendSheet = workbook.addWorksheet("Legend"); + legendSheet.addRow(["Excel Template Legend"]); + legendSheet.addRow([]); + legendSheet.addRow(["Symbol", "Description"]); + legendSheet.addRow(["Red background header", "SHI-only fields that cannot be edited"]); + legendSheet.addRow(["Gray background header", "Regular editable fields"]); + legendSheet.addRow(["Light red background cells", "Data in SHI-only fields (read-only)"]); + legendSheet.addRow(["Red text color", "SHI-only field headers"]); + + // 범례 스타일 적용 + const legendHeaderRow = legendSheet.getRow(1); + legendHeaderRow.font = { bold: true, size: 14 }; + + const legendTableHeader = legendSheet.getRow(3); + legendTableHeader.font = { bold: true }; + legendTableHeader.eachCell((cell) => { + cell.fill = { + type: "pattern", + pattern: "solid", + fgColor: { argb: "FFCCCCCC" }, + }; + }); + + // 7. 파일 다운로드 const buffer = await workbook.xlsx.writeBuffer(); saveAs( new Blob([buffer]), diff --git a/components/form-data/form-data-table-columns.tsx b/components/form-data/form-data-table-columns.tsx index b088276e..bba2a208 100644 --- a/components/form-data/form-data-table-columns.tsx +++ b/components/form-data/form-data-table-columns.tsx @@ -22,7 +22,7 @@ import { toast } from 'sonner'; /** row 액션 관련 타입 */ export interface DataTableRowAction<TData> { row: Row<TData>; - type: "open" | "edit" | "update"; + type: "open" | "edit" | "update" | "delete"; } /** 컬럼 타입 (필요에 따라 확장) */ @@ -57,7 +57,7 @@ interface GetColumnsProps<TData> { // 체크박스 선택 관련 props selectedRows?: Record<string, boolean>; onRowSelectionChange?: (updater: Record<string, boolean> | ((prev: Record<string, boolean>) => Record<string, boolean>)) => void; - editableFieldsMap?: Map<string, string[]>; // 새로 추가 + // editableFieldsMap 제거됨 } /** @@ -73,7 +73,7 @@ export function getColumns<TData extends object>({ tempCount, selectedRows = {}, onRowSelectionChange, - editableFieldsMap = new Map(), // 새로 추가 + // editableFieldsMap 매개변수 제거됨 }: GetColumnsProps<TData>): ColumnDef<TData>[] { const columns: ColumnDef<TData>[] = []; @@ -122,6 +122,8 @@ export function getColumns<TData extends object>({ ), enableSorting: false, enableHiding: false, + enablePinning: true, // ← 이 줄 추가 + size: 40, }; columns.push(selectColumn); @@ -147,38 +149,16 @@ export function getColumns<TData extends object>({ cell: ({ row }) => { const cellValue = row.getValue(col.key); - // 기본 읽기 전용 여부 (shi 속성 기반) - let isReadOnly = col.shi === true; - - // 동적 읽기 전용 여부 계산 - if (!isReadOnly && col.key !== 'TAG_NO' && col.key !== 'TAG_DESC') { - const tagNo = row.getValue('TAG_NO') as string; - if (tagNo && editableFieldsMap.has(tagNo)) { - const editableFields = editableFieldsMap.get(tagNo) || []; - // 해당 TAG의 편집 가능 필드 목록에 없으면 읽기 전용 - isReadOnly = !editableFields.includes(col.key); - } else { - // TAG_NO가 없거나 editableFieldsMap에 없으면 읽기 전용 - isReadOnly = true; - } - } + // SHI 필드만 읽기 전용으로 처리 + const isReadOnly = col.shi === true; const readOnlyClass = isReadOnly ? "read-only-cell" : ""; const cellStyle = isReadOnly ? { backgroundColor: '#f5f5f5', color: '#666', cursor: 'not-allowed' } : {}; - // 툴팁 메시지 설정 - let tooltipMessage = ""; - if (isReadOnly) { - if (col.shi === true) { - tooltipMessage = "SHI 전용 필드입니다"; - } else if (col.key === 'TAG_NO' || col.key === 'TAG_DESC') { - tooltipMessage = "기본 필드는 수정할 수 없습니다"; - } else { - tooltipMessage = "이 TAG 클래스에서는 편집할 수 없는 필드입니다"; - } - } + // 툴팁 메시지 설정 (SHI 필드만) + const tooltipMessage = isReadOnly ? "SHI 전용 필드입니다" : ""; // 데이터 타입별 처리 switch (col.type) { @@ -220,6 +200,7 @@ export function getColumns<TData extends object>({ })); columns.push(...baseColumns); + // (4) 액션 칼럼 - update 버튼 예시 const actionColumn: ColumnDef<TData> = { id: "update", @@ -255,10 +236,19 @@ export function getColumns<TData extends object>({ > Create Document </DropdownMenuItem> + <DropdownMenuSeparator /> + <DropdownMenuItem + onSelect={() => { + setRowAction({ row, type: "delete" }); + }} + className="text-red-600 focus:text-red-600" + > + Delete + </DropdownMenuItem> </DropdownMenuContent> </DropdownMenu> ), - minSize: 50, + size: 40, enablePinning: true, }; diff --git a/components/form-data/form-data-table.tsx b/components/form-data/form-data-table.tsx index 6de6dd0b..9a438957 100644 --- a/components/form-data/form-data-table.tsx +++ b/components/form-data/form-data-table.tsx @@ -13,21 +13,22 @@ import { } from "./form-data-table-columns"; import type { DataTableAdvancedFilterField } from "@/types/table"; import { Button } from "../ui/button"; -import { - Download, - Loader, - Save, - Upload, - Plus, - Tag, - TagsIcon, - FileText, +import { + Download, + Loader, + Save, + Upload, + Plus, + Tag, + TagsIcon, + FileText, FileSpreadsheet, FileOutput, - Clipboard, + Clipboard, Send, GitCompareIcon, - RefreshCcw + RefreshCcw, + Trash2 } from "lucide-react"; import { toast } from "sonner"; import { @@ -54,16 +55,17 @@ import { exportExcelData } from "./export-excel-form"; import { SEDPConfirmationDialog, SEDPStatusDialog } from "./sedp-components"; import { SEDPCompareDialog } from "./sedp-compare-dialog"; import { getSEDPToken } from "@/lib/sedp/sedp-token"; +import { DeleteFormDataDialog } from "./delete-form-data-dialog"; // 새로 추가 async function fetchTagDataFromSEDP(projectCode: string, formCode: string): Promise<any> { try { // Get the token const apiKey = await getSEDPToken(); - + // Define the API base URL const SEDP_API_BASE_URL = process.env.SEDP_API_BASE_URL || 'http://sedpwebapi.ship.samsung.co.kr/api'; - + // Make the API call const response = await fetch( `${SEDP_API_BASE_URL}/Data/GetPubData`, @@ -82,12 +84,12 @@ async function fetchTagDataFromSEDP(projectCode: string, formCode: string): Prom }) } ); - + if (!response.ok) { const errorText = await response.text(); throw new Error(`SEDP API request failed: ${response.status} ${response.statusText} - ${errorText}`); } - + const data = await response.json(); return data; } catch (error: any) { @@ -119,7 +121,7 @@ export default function DynamicTable({ contractItemId, formCode, formId, - projectId, + projectId, mode = "IM", // 기본값 설정 formName = `${formCode}`, // Default form name based on formCode editableFieldsMap = new Map(), // 새로 추가 @@ -134,19 +136,21 @@ export default function DynamicTable({ const [tableData, setTableData] = React.useState<GenericData[]>(dataJSON); // 배치 선택 관련 상태 - const [selectedRows, setSelectedRows] = React.useState<Record<string, boolean>>({}); + const [selectedRowsData, setSelectedRowsData] = React.useState<GenericData[]>([]); + const [clearSelection, setClearSelection] = React.useState(false); + // 삭제 관련 상태 간소화 + const [deleteDialogOpen, setDeleteDialogOpen] = React.useState(false); + const [deleteTarget, setDeleteTarget] = React.useState<GenericData[]>([]); // Update tableData when dataJSON changes React.useEffect(() => { setTableData(dataJSON); - // 데이터가 변경되면 선택 상태 초기화 - setSelectedRows({}); }, [dataJSON]); - + // 폴링 상태 관리를 위한 ref const pollingRef = React.useRef<NodeJS.Timeout | null>(null); const [syncId, setSyncId] = React.useState<string | null>(null); - + // Separate loading states for different operations const [isSyncingTags, setIsSyncingTags] = React.useState(false); const [isImporting, setIsImporting] = React.useState(false); @@ -154,10 +158,10 @@ export default function DynamicTable({ const [isSaving, setIsSaving] = React.useState(false); const [isSendingSEDP, setIsSendingSEDP] = React.useState(false); const [isLoadingTags, setIsLoadingTags] = React.useState(false); - + // Any operation in progress const isAnyOperationPending = isSyncingTags || isImporting || isExporting || isSaving || isSendingSEDP || isLoadingTags; - + // SEDP dialogs state const [sedpConfirmOpen, setSedpConfirmOpen] = React.useState(false); const [sedpStatusOpen, setSedpStatusOpen] = React.useState(false); @@ -168,11 +172,11 @@ export default function DynamicTable({ errorCount: 0, totalCount: 0 }); - + // SEDP compare dialog state const [sedpCompareOpen, setSedpCompareOpen] = React.useState(false); const [projectCode, setProjectCode] = React.useState<string>(''); - + const [tempUpDialog, setTempUpDialog] = React.useState(false); const [reportData, setReportData] = React.useState<GenericData[]>([]); const [batchDownDialog, setBatchDownDialog] = React.useState(false); @@ -216,26 +220,22 @@ export default function DynamicTable({ // 선택된 행들의 실제 데이터 가져오기 const getSelectedRowsData = React.useCallback(() => { - const selectedIndices = Object.keys(selectedRows).filter(key => selectedRows[key]); - return selectedIndices.map(index => tableData[parseInt(index)]).filter(Boolean); - }, [selectedRows, tableData]); + return selectedRowsData; + }, [selectedRowsData]); // 선택된 행 개수 계산 const selectedRowCount = React.useMemo(() => { - return Object.values(selectedRows).filter(Boolean).length; - }, [selectedRows]); + return selectedRowsData.length; + }, [selectedRowsData]); const columns = React.useMemo( - () => getColumns<GenericData>({ - columnsJSON, - setRowAction, - setReportData, + () => getColumns<GenericData>({ + columnsJSON, + setRowAction, + setReportData, tempCount, - selectedRows, - onRowSelectionChange: setSelectedRows, - editableFieldsMap }), - [columnsJSON, setRowAction, setReportData, tempCount, selectedRows] + [columnsJSON, setRowAction, setReportData, tempCount] ); function mapColumnTypeToAdvancedFilterType( @@ -297,30 +297,30 @@ export default function DynamicTable({ setIsSyncingTags(false); } } - + // ENG 모드: 태그 가져오기 함수 const handleGetTags = async () => { try { setIsLoadingTags(true); - + // API 엔드포인트 호출 - 작업 시작만 요청 const response = await fetch('/api/cron/form-tags/start', { method: 'POST', - body: JSON.stringify({ projectCode ,formCode ,contractItemId }) + body: JSON.stringify({ projectCode, formCode, contractItemId }) }); - + if (!response.ok) { const errorData = await response.json(); throw new Error(errorData.error || 'Failed to start tag import'); } - + const data = await response.json(); - + // 작업 ID 저장 if (data.syncId) { setSyncId(data.syncId); toast.info('Tag import started. This may take a while...'); - + // 상태 확인을 위한 폴링 시작 startPolling(data.syncId); } else { @@ -336,49 +336,49 @@ export default function DynamicTable({ setIsLoadingTags(false); } }; - + const startPolling = (id: string) => { // 이전 폴링이 있다면 제거 if (pollingRef.current) { clearInterval(pollingRef.current); } - + // 5초마다 상태 확인 pollingRef.current = setInterval(async () => { try { const response = await fetch(`/api/cron/form-tags/status?id=${id}`); - + if (!response.ok) { throw new Error('Failed to get tag import status'); } - + const data = await response.json(); - + if (data.status === 'completed') { // 폴링 중지 if (pollingRef.current) { clearInterval(pollingRef.current); pollingRef.current = null; } - + router.refresh(); - + // 상태 초기화 setIsLoadingTags(false); setSyncId(null); - + // 성공 메시지 표시 toast.success( `Tags imported successfully! ${data.result?.processedCount || 0} items processed.` ); - + } else if (data.status === 'failed') { // 에러 처리 if (pollingRef.current) { clearInterval(pollingRef.current); pollingRef.current = null; } - + setIsLoadingTags(false); setSyncId(null); toast.error(data.error || 'Import failed'); @@ -395,15 +395,16 @@ export default function DynamicTable({ } }, 5000); // 5초마다 체크 }; - - // Excel Import - Modified to directly save to DB + + // Excel Import - Fixed version with proper loading state management async function handleImportExcel(e: React.ChangeEvent<HTMLInputElement>) { const file = e.target.files?.[0]; if (!file) return; - + try { - setIsImporting(true); - + // Don't set setIsImporting here - let importExcelData handle it completely + // setIsImporting(true); // Remove this line + // Call the updated importExcelData function with editableFieldsMap const result = await importExcelData({ file, @@ -411,28 +412,37 @@ export default function DynamicTable({ columnsJSON, formCode, contractItemId, - editableFieldsMap, // 추가: 편집 가능 필드 정보 전달 - onPendingChange: setIsImporting, + // editableFieldsMap, // 추가: 편집 가능 필드 정보 전달 + onPendingChange: setIsImporting, // Let importExcelData handle loading state onDataUpdate: (newData) => { setTableData(Array.isArray(newData) ? newData : newData(tableData)); } }); - + // If import and save was successful, refresh the page if (result.success) { // Show additional info about skipped fields if any if (result.skippedFields && result.skippedFields.length > 0) { console.log("Import completed with some fields skipped:", result.skippedFields); } - router.refresh(); + + // Ensure loading state is cleared before refresh + setIsImporting(false); + + // Add a small delay to ensure state update is processed + setTimeout(() => { + router.refresh(); + }, 100); } } catch (error) { console.error("Import failed:", error); toast.error("Failed to import Excel data"); + // Ensure loading state is cleared on error + setIsImporting(false); } finally { // Always clear the file input value e.target.value = ""; - setIsImporting(false); + // Don't set setIsImporting(false) here since we handle it above } } // SEDP Send handler (with confirmation) @@ -441,23 +451,23 @@ export default function DynamicTable({ toast.error("No data to send to SEDP"); return; } - + // Open confirmation dialog setSedpConfirmOpen(true); } - + // Handle SEDP compare button click function handleSEDPCompareClick() { if (tableData.length === 0) { toast.error("No data to compare with SEDP"); return; } - + if (!projectCode) { toast.error("Project code is not available"); return; } - + // Open compare dialog setSedpCompareOpen(true); } @@ -466,7 +476,7 @@ export default function DynamicTable({ async function handleSEDPSendConfirmed() { try { setIsSendingSEDP(true); - + // Validate data const invalidData = tableData.filter((item) => !item.TAG_NO?.trim()); if (invalidData.length > 0) { @@ -479,13 +489,14 @@ export default function DynamicTable({ const sedpResult = await sendFormDataToSEDP( formCode, // Send formCode instead of formName projectId, // Project ID + contractItemId, tableData, // Table data columnsJSON // Column definitions ); // Close confirmation dialog setSedpConfirmOpen(false); - + // Set status data based on result if (sedpResult.success) { setSedpStatusData({ @@ -504,16 +515,16 @@ export default function DynamicTable({ totalCount: tableData.length }); } - + // Open status dialog to show result setSedpStatusOpen(true); - + // Refresh the route to get fresh data router.refresh(); - + } catch (err: any) { console.error("SEDP error:", err); - + // Set error status setSedpStatusData({ status: 'error', @@ -522,16 +533,16 @@ export default function DynamicTable({ errorCount: tableData.length, totalCount: tableData.length }); - + // Close confirmation and open status setSedpConfirmOpen(false); setSedpStatusOpen(true); - + } finally { setIsSendingSEDP(false); } } - + // Template Export async function handleExportExcel() { try { @@ -561,24 +572,76 @@ export default function DynamicTable({ } else { toast.info(`전체 ${tableData.length}개 항목으로 배치 문서를 생성합니다.`); } - + setBatchDownDialog(true); }; + // 개별 행 삭제 핸들러 + const handleDeleteRow = (rowData: GenericData) => { + setDeleteTarget([rowData]); + setDeleteDialogOpen(true); + }; + + // 배치 삭제 핸들러 + const handleBatchDelete = () => { + const selectedData = getSelectedRowsData(); + if (selectedData.length === 0) { + toast.error("삭제할 항목을 선택해주세요."); + return; + } + + setDeleteTarget(selectedData); + setDeleteDialogOpen(true); + }; + + // 삭제 성공 후 처리 + const handleDeleteSuccess = () => { + // 로컬 상태에서 삭제된 항목들 제거 + const tagNosToDelete = deleteTarget + .map(item => item.TAG_NO) + .filter(Boolean); + + setTableData(prev => + prev.filter(item => !tagNosToDelete.includes(item.TAG_NO)) + ); + + // 선택 상태 초기화 + setSelectedRowsData([]); + setClearSelection(prev => !prev); // ClientDataTable의 선택 상태 초기화 + + // 삭제 타겟 초기화 + setDeleteTarget([]); + }; + + // rowAction 처리 부분 수정 + React.useEffect(() => { + if (rowAction?.type === "delete") { + handleDeleteRow(rowAction.row.original); + setRowAction(null); // 액션 초기화 + } + }, [rowAction]); + + return ( <> <ClientDataTable data={tableData} columns={columns} advancedFilterFields={advancedFilterFields} + autoSizeColumns + onSelectedRowsChange={setSelectedRowsData} + clearSelection={clearSelection} > {/* 선택된 항목 수 표시 (선택된 항목이 있을 때만) */} {selectedRowCount > 0 && ( - <div className="mb-4 p-3 bg-blue-50 border border-blue-200 rounded-md"> - <p className="text-sm text-blue-700"> - {selectedRowCount}개 항목이 선택되었습니다. 배치 문서는 선택된 항목만으로 생성됩니다. - </p> - </div> + <Button + variant="destructive" + size="sm" + onClick={handleBatchDelete} + > + <Trash2 className="mr-2 size-4" /> + Delete ({selectedRowCount}) + </Button> )} {/* 버튼 그룹 */} @@ -613,7 +676,7 @@ export default function DynamicTable({ </DropdownMenuItem> </DropdownMenuContent> </DropdownMenu> - + {/* 리포트 관리 드롭다운 */} <DropdownMenu> <DropdownMenuTrigger asChild> @@ -707,6 +770,7 @@ export default function DynamicTable({ </ClientDataTable> {/* Modal dialog for tag update */} + {/* Modal dialog for tag update */} <UpdateTagSheet open={rowAction?.type === "update"} onOpenChange={(open) => { @@ -716,21 +780,36 @@ export default function DynamicTable({ rowData={rowAction?.row.original ?? null} formCode={formCode} contractItemId={contractItemId} + editableFieldsMap={editableFieldsMap} // 새로 추가 onUpdateSuccess={(updatedValues) => { // Update the specific row in tableData when a single row is updated if (rowAction?.row.original?.TAG_NO) { const tagNo = rowAction.row.original.TAG_NO; - setTableData(prev => - prev.map(item => + setTableData(prev => + prev.map(item => item.TAG_NO === tagNo ? updatedValues : item ) ); } }} /> - + <DeleteFormDataDialog + formData={deleteTarget} + formCode={formCode} + contractItemId={contractItemId} + open={deleteDialogOpen} + onOpenChange={(open) => { + if (!open) { + setDeleteDialogOpen(false); + setDeleteTarget([]); + } + }} + onSuccess={handleDeleteSuccess} + showTrigger={false} + /> + {/* Dialog for adding tags */} - <AddFormTagDialog + <AddFormTagDialog projectId={projectId} formCode={formCode} formName={`Form ${formCode}`} @@ -738,7 +817,7 @@ export default function DynamicTable({ open={addTagDialogOpen} onOpenChange={setAddTagDialogOpen} /> - + {/* SEDP Confirmation Dialog */} <SEDPConfirmationDialog isOpen={sedpConfirmOpen} @@ -748,7 +827,7 @@ export default function DynamicTable({ tagCount={tableData.length} isLoading={isSendingSEDP} /> - + {/* SEDP Status Dialog */} <SEDPStatusDialog isOpen={sedpStatusOpen} @@ -759,7 +838,7 @@ export default function DynamicTable({ errorCount={sedpStatusData.errorCount} totalCount={sedpStatusData.totalCount} /> - + {/* SEDP Compare Dialog */} <SEDPCompareDialog isOpen={sedpCompareOpen} @@ -770,7 +849,7 @@ export default function DynamicTable({ formCode={formCode} fetchTagDataFromSEDP={fetchTagDataFromSEDP} /> - + {/* Other dialogs */} {tempUpDialog && ( <FormDataReportTempUploadDialog diff --git a/components/form-data/import-excel-form.tsx b/components/form-data/import-excel-form.tsx index f32e44d8..6f0828b0 100644 --- a/components/form-data/import-excel-form.tsx +++ b/components/form-data/import-excel-form.tsx @@ -5,14 +5,13 @@ import { DataTableColumnJSON } from "./form-data-table-columns"; import { updateFormDataInDB } from "@/lib/forms/services"; import { decryptWithServerAction } from "../drm/drmUtils"; -// Enhanced options interface with editableFieldsMap +// Simplified options interface without editableFieldsMap export interface ImportExcelOptions { file: File; tableData: GenericData[]; columnsJSON: DataTableColumnJSON[]; formCode?: string; contractItemId?: number; - editableFieldsMap?: Map<string, string[]>; // 새로 추가 onPendingChange?: (isPending: boolean) => void; onDataUpdate?: (updater: ((prev: GenericData[]) => GenericData[]) | GenericData[]) => void; } @@ -42,7 +41,6 @@ export async function importExcelData({ columnsJSON, formCode, contractItemId, - editableFieldsMap = new Map(), // 기본값으로 빈 Map onPendingChange, onDataUpdate }: ImportExcelOptions): Promise<ImportExcelResult> { @@ -141,47 +139,18 @@ export async function importExcelData({ const rowObj: Record<string, any> = {}; const skippedFields: string[] = []; // 현재 행에서 건너뛴 필드들 - // Get the TAG_NO first to identify existing data and editable fields + // Get the TAG_NO first to identify existing data const tagNoColIndex = keyToIndexMap.get("TAG_NO"); const tagNo = tagNoColIndex ? String(rowValues[tagNoColIndex] ?? "").trim() : ""; const existingRowData = existingDataMap.get(tagNo); - - // Get editable fields for this specific TAG - const editableFields = editableFieldsMap.has(tagNo) ? editableFieldsMap.get(tagNo)! : []; // Process each column columnsJSON.forEach((col) => { const colIndex = keyToIndexMap.get(col.key); if (colIndex === undefined) return; - // Determine if this field is editable - let isFieldEditable = true; - let skipReason = ""; - - // 1. Check if this is a SHI-only field + // Check if this is a SHI-only field (skip processing but preserve existing value) if (col.shi === true) { - isFieldEditable = false; - skipReason = "SHI-only field"; - } - // 2. Check if this field is editable based on TAG class attributes - else if (col.key !== "TAG_NO" && col.key !== "TAG_DESC") { - // For non-basic fields, check if they're in the editable list - if (tagNo && editableFieldsMap.has(tagNo)) { - if (!editableFields.includes(col.key)) { - isFieldEditable = false; - skipReason = "Not editable for this TAG class"; - } - } else if (tagNo) { - // If TAG exists but no editable fields info, treat as not editable - isFieldEditable = false; - skipReason = "No editable fields info for this TAG"; - } - } - // 3. TAG_NO and TAG_DESC are always considered basic fields - // (They should be editable, but you might want to add specific logic here) - - // If field is not editable, use existing value or default - if (!isFieldEditable) { if (existingRowData && existingRowData[col.key] !== undefined) { rowObj[col.key] = existingRowData[col.key]; } else { @@ -199,7 +168,7 @@ export async function importExcelData({ } // Log skipped field - skippedFields.push(`${col.label} (${skipReason})`); + skippedFields.push(`${col.label} (SHI-only field)`); return; // Skip processing Excel value for this column } @@ -254,7 +223,7 @@ export async function importExcelData({ tagNo: tagNo, fields: skippedFields }); - warningMessage += `Skipped ${skippedFields.length} non-editable fields. `; + warningMessage += `Skipped ${skippedFields.length} SHI-only fields. `; } // Validate TAG_NO @@ -283,7 +252,7 @@ export async function importExcelData({ const totalSkippedFields = skippedFieldsLog.reduce((sum, log) => sum + log.fields.length, 0); console.log("Skipped fields summary:", skippedFieldsLog); toast.info( - `${totalSkippedFields} non-editable fields were skipped across ${skippedFieldsLog.length} rows. Check console for details.` + `${totalSkippedFields} SHI-only fields were skipped across ${skippedFieldsLog.length} rows. Check console for details.` ); } @@ -391,7 +360,7 @@ export async function importExcelData({ } const successMessage = skippedFieldsLog.length > 0 - ? `Successfully updated ${successCount} rows (some non-editable fields were preserved)` + ? `Successfully updated ${successCount} rows (SHI-only fields were preserved)` : `Successfully updated ${successCount} rows`; toast.success(successMessage); @@ -417,7 +386,7 @@ export async function importExcelData({ } const successMessage = skippedFieldsLog.length > 0 - ? `Imported ${importedData.length} rows successfully (some fields preserved)` + ? `Imported ${importedData.length} rows successfully (SHI-only fields preserved)` : `Imported ${importedData.length} rows successfully`; toast.success(`${successMessage} (local only)`); @@ -435,4 +404,4 @@ export async function importExcelData({ } finally { if (onPendingChange) onPendingChange(false); } -} +}
\ No newline at end of file diff --git a/components/form-data/update-form-sheet.tsx b/components/form-data/update-form-sheet.tsx index 6f2a4722..c8772e2a 100644 --- a/components/form-data/update-form-sheet.tsx +++ b/components/form-data/update-form-sheet.tsx @@ -54,6 +54,7 @@ interface UpdateTagSheetProps rowData: Record<string, any> | null; formCode: string; contractItemId: number; + editableFieldsMap?: Map<string, string[]>; // 새로 추가 /** 업데이트 성공 시 호출될 콜백 */ onUpdateSuccess?: (updatedValues: Record<string, any>) => void; } @@ -65,12 +66,66 @@ export function UpdateTagSheet({ rowData, formCode, contractItemId, + editableFieldsMap = new Map(), // 기본값 설정 onUpdateSuccess, ...props }: UpdateTagSheetProps) { const [isPending, startTransition] = React.useTransition(); const router = useRouter(); + // 현재 TAG의 편집 가능한 필드 목록 가져오기 + const editableFields = React.useMemo(() => { + if (!rowData?.TAG_NO || !editableFieldsMap.has(rowData.TAG_NO)) { + return []; + } + return editableFieldsMap.get(rowData.TAG_NO) || []; + }, [rowData?.TAG_NO, editableFieldsMap]); + + // 필드가 편집 가능한지 판별하는 함수 + const isFieldEditable = React.useCallback((column: DataTableColumnJSON) => { + // 1. SHI-only 필드는 편집 불가 + if (column.shi === true) { + return false; + } + + // 2. TAG_NO와 TAG_DESC는 기본적으로 편집 가능 (필요에 따라 수정 가능) + if (column.key === "TAG_NO" || column.key === "TAG_DESC") { + return true; + } + + // 3. editableFieldsMap이 있으면 해당 리스트에 있는지 확인 + if (rowData?.TAG_NO && editableFieldsMap.has(rowData.TAG_NO)) { + return editableFields.includes(column.key); + } + + // 4. editableFieldsMap 정보가 없으면 기본적으로 편집 불가 (안전한 기본값) + return false; + }, [rowData?.TAG_NO, editableFieldsMap, editableFields]); + + // 읽기 전용 필드인지 판별하는 함수 (편집 가능의 반대) + const isFieldReadOnly = React.useCallback((column: DataTableColumnJSON) => { + return !isFieldEditable(column); + }, [isFieldEditable]); + + // 읽기 전용 사유를 반환하는 함수 + const getReadOnlyReason = React.useCallback((column: DataTableColumnJSON) => { + if (column.shi === true) { + return "SHI-only field (managed by SHI system)"; + } + + if (column.key !== "TAG_NO" && column.key !== "TAG_DESC") { + if (!rowData?.TAG_NO || !editableFieldsMap.has(rowData.TAG_NO)) { + return "No editable fields information for this TAG"; + } + + if (!editableFields.includes(column.key)) { + return "Not editable for this TAG class"; + } + } + + return "Read-only field"; + }, [rowData?.TAG_NO, editableFieldsMap, editableFields]); + // 1) zod 스키마 const dynamicSchema = React.useMemo(() => { const shape: Record<string, z.ZodType<any>> = {}; @@ -118,7 +173,7 @@ export function UpdateTagSheet({ // 제출 전에 읽기 전용 필드를 원본 값으로 복원 const finalValues = { ...values }; for (const col of columns) { - if (col.shi || col.key === "TAG_NO" || col.key === "TAG_DESC") { + if (isFieldReadOnly(col)) { // 읽기 전용 필드는 원본 값으로 복원 finalValues[col.key] = rowData?.[col.key] ?? ""; } @@ -161,13 +216,22 @@ export function UpdateTagSheet({ }); } + // 편집 가능한 필드 개수 계산 + const editableFieldCount = React.useMemo(() => { + return columns.filter(col => isFieldEditable(col)).length; + }, [columns, isFieldEditable]); + return ( <Sheet open={open} onOpenChange={onOpenChange} {...props}> <SheetContent className="sm:max-w-xl md:max-w-3xl lg:max-w-4xl xl:max-w-5xl flex flex-col"> <SheetHeader className="text-left"> - <SheetTitle>Update Row</SheetTitle> + <SheetTitle>Update Row - {rowData?.TAG_NO || 'Unknown TAG'}</SheetTitle> <SheetDescription> Modify the fields below and save changes. Fields with <LockIcon className="inline h-3 w-3" /> are read-only. + <br /> + <span className="text-sm text-green-600"> + {editableFieldCount} of {columns.length} fields are editable for this TAG. + </span> </SheetDescription> </SheetHeader> @@ -179,10 +243,8 @@ export function UpdateTagSheet({ <div className="overflow-y-auto max-h-[80vh] flex-1 pr-4 -mr-4"> <div className="flex flex-col gap-4 pt-2"> {columns.map((col) => { - // 읽기 전용 조건 업데이트: shi가 true이거나 TAG_NO/TAG_DESC인 경우 - const isReadOnly = col.shi === true || - col.key === "TAG_NO" || - col.key === "TAG_DESC"; + const isReadOnly = isFieldReadOnly(col); + const readOnlyReason = isReadOnly ? getReadOnlyReason(col) : ""; return ( <FormField @@ -214,9 +276,9 @@ export function UpdateTagSheet({ )} /> </FormControl> - {isReadOnly && col.shi && ( + {isReadOnly && ( <FormDescription className="text-xs text-gray-500"> - This field is read-only + {readOnlyReason} </FormDescription> )} <FormMessage /> @@ -278,31 +340,15 @@ export function UpdateTagSheet({ </Command> </PopoverContent> </Popover> - {isReadOnly && col.shi && ( + {isReadOnly && ( <FormDescription className="text-xs text-gray-500"> - This field is read-only + {readOnlyReason} </FormDescription> )} <FormMessage /> </FormItem> ); - // case "date": - // return ( - // <FormItem> - // <FormLabel>{col.label}</FormLabel> - // <FormControl> - // <Input - // type="date" - // readOnly={isReadOnly} - // onChange={field.onChange} - // value={field.value ?? ""} - // /> - // </FormControl> - // <FormMessage /> - // </FormItem> - // ) - case "STRING": default: return ( @@ -322,9 +368,9 @@ export function UpdateTagSheet({ )} /> </FormControl> - {isReadOnly && col.shi && ( + {isReadOnly && ( <FormDescription className="text-xs text-gray-500"> - This field is read-only + {readOnlyReason} </FormDescription> )} <FormMessage /> diff --git a/components/layout/sidebar-nav.tsx b/components/layout/sidebar-nav.tsx index addcfefd..50c2c229 100644 --- a/components/layout/sidebar-nav.tsx +++ b/components/layout/sidebar-nav.tsx @@ -23,6 +23,7 @@ export function SidebarNav({ className, items, ...props }: SidebarNavProps) { className )} {...props} + > {items.map((item) => ( <Link |
