summaryrefslogtreecommitdiff
path: root/components
diff options
context:
space:
mode:
authordujinkim <dujin.kim@dtsolution.co.kr>2025-06-01 13:52:21 +0000
committerdujinkim <dujin.kim@dtsolution.co.kr>2025-06-01 13:52:21 +0000
commitbac0228d21b7195065e9cddcc327ae33659c7bcc (patch)
tree8f3016ae4533c8706d0c00a605d9b1d41968c2bc /components
parent2fdce8d7a57c792bba0ac36fa554dca9c9cc31e3 (diff)
(대표님) 20250601까지 작업사항
Diffstat (limited to 'components')
-rw-r--r--components/client-data-table/data-table-column-simple-header.tsx2
-rw-r--r--components/client-data-table/data-table.tsx74
-rw-r--r--components/data-table/data-table-pagination.tsx219
-rw-r--r--components/data-table/data-table-pin-right.tsx2
-rw-r--r--components/data-table/data-table.tsx6
-rw-r--r--components/data-table/expandable-data-table.tsx707
-rw-r--r--components/data-table/infinite-data-table.tsx69
-rw-r--r--components/data-table/view-mode-toggle.tsx91
-rw-r--r--components/form-data/delete-form-data-dialog.tsx217
-rw-r--r--components/form-data/export-excel-form.tsx97
-rw-r--r--components/form-data/form-data-table-columns.tsx50
-rw-r--r--components/form-data/form-data-table.tsx265
-rw-r--r--components/form-data/import-excel-form.tsx49
-rw-r--r--components/form-data/update-form-sheet.tsx102
-rw-r--r--components/layout/sidebar-nav.tsx1
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