summaryrefslogtreecommitdiff
path: root/components
diff options
context:
space:
mode:
authordujinkim <dujin.kim@dtsolution.co.kr>2025-05-29 05:17:13 +0000
committerdujinkim <dujin.kim@dtsolution.co.kr>2025-05-29 05:17:13 +0000
commit37f55540833c2d5894513eca9fc8f7c6233fc2d2 (patch)
tree6807978e7150358b3444c33b825c83e2c9cda8e8 /components
parent4b9bdb29e637f67761beb2db7f75dab0432d6712 (diff)
(대표님) 0529 14시 16분 변경사항 저장 (Vendor Data, Docu)
Diffstat (limited to 'components')
-rw-r--r--components/data-table/data-table-pagination.tsx219
-rw-r--r--components/data-table/infinite-data-table.tsx294
-rw-r--r--components/form-data/form-data-table-columns.tsx47
-rw-r--r--components/form-data/form-data-table.tsx20
-rw-r--r--components/form-data/import-excel-form.tsx117
5 files changed, 590 insertions, 107 deletions
diff --git a/components/data-table/data-table-pagination.tsx b/components/data-table/data-table-pagination.tsx
index 4ed63a1b..922dacf1 100644
--- a/components/data-table/data-table-pagination.tsx
+++ b/components/data-table/data-table-pagination.tsx
@@ -7,6 +7,7 @@ import {
ChevronRight,
ChevronsLeft,
ChevronsRight,
+ Infinity,
} from "lucide-react"
import { Button } from "@/components/ui/button"
@@ -21,57 +22,99 @@ 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으로 처리할 것이므로,
- // 만약 현재 pageSize가 1,000,000이면 화면상 "All"로 표시
- const selectValue =
- currentPageSize === 1_000_000
- ? "All"
- : String(currentPageSize)
+ // "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 상태 변경이 테이블 상태로 자동 반영됨
+ }
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{" "}
- {table.getFilteredRowModel().rows.length} row(s) selected.
- <span className="ml-4">Total: {table.getRowCount()} records</span>
+ {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>
+ </>
+ )}
</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">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))
- }
- }}
- >
+ <p className="whitespace-nowrap text-sm font-medium">
+ {isInfiniteMode ? "View mode" : "Rows per page"}
+ </p>
+ <Select value={selectValue} onValueChange={handlePageSizeChange}>
<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}>
- {label}
+ <div className="flex items-center space-x-2">
+ {option === "All" && (
+ <Infinity className="h-3 w-3 text-muted-foreground" />
+ )}
+ <span>{label}</span>
+ </div>
</SelectItem>
)
})}
@@ -79,54 +122,90 @@ export function DataTablePagination<TData>({
</Select>
</div>
- {/* 현재 페이지 / 전체 페이지 */}
- <div className="flex items-center justify-center text-sm font-medium">
- Page {table.getState().pagination.pageIndex + 1} of{" "}
- {table.getPageCount()}
- </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 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>
+ </>
+ )}
+
+ {/* 무한 스크롤 모드일 때 로드 더 버튼 */}
+ {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>
</div>
)
diff --git a/components/data-table/infinite-data-table.tsx b/components/data-table/infinite-data-table.tsx
new file mode 100644
index 00000000..fcac56ee
--- /dev/null
+++ b/components/data-table/infinite-data-table.tsx
@@ -0,0 +1,294 @@
+"use client"
+
+import * as React from "react"
+import { flexRender, type Table as TanstackTable } from "@tanstack/react-table"
+import { ChevronRight, ChevronUp, Loader2 } from "lucide-react"
+import { useIntersection } from "@mantine/hooks"
+
+import { cn } from "@/lib/utils"
+import { getCommonPinningStyles } from "@/lib/data-table"
+import {
+ Table,
+ TableBody,
+ TableCell,
+ TableHead,
+ TableHeader,
+ TableRow,
+} from "@/components/ui/table"
+import { Button } from "@/components/ui/button"
+import { DataTableResizer } from "@/components/data-table/data-table-resizer"
+import { useAutoSizeColumns } from "@/hooks/useAutoSizeColumns"
+
+interface InfiniteDataTableProps<TData> extends React.HTMLAttributes<HTMLDivElement> {
+ table: TanstackTable<TData>
+ floatingBar?: React.ReactNode | null
+ autoSizeColumns?: boolean
+ compact?: boolean
+ // 무한 스크롤 관련 props
+ hasNextPage?: boolean
+ isLoadingMore?: boolean
+ onLoadMore?: () => void
+ totalCount?: number | null
+ isEmpty?: boolean
+}
+
+/**
+ * 무한 스크롤 지원 DataTable
+ */
+export function InfiniteDataTable<TData>({
+ table,
+ floatingBar = null,
+ autoSizeColumns = true,
+ compact = false,
+ hasNextPage = false,
+ isLoadingMore = false,
+ onLoadMore,
+ totalCount = null,
+ isEmpty = false,
+ children,
+ className,
+ maxHeight,
+ ...props
+}: InfiniteDataTableProps<TData> & { maxHeight?: string | number }) {
+
+ useAutoSizeColumns(table, autoSizeColumns)
+
+ // Intersection Observer for infinite scroll
+ const { ref: loadMoreRef, entry } = useIntersection({
+ threshold: 0.1,
+ })
+
+ // 자동 로딩 트리거
+ React.useEffect(() => {
+ if (entry?.isIntersecting && hasNextPage && !isLoadingMore && onLoadMore) {
+ onLoadMore()
+ }
+ }, [entry?.isIntersecting, hasNextPage, isLoadingMore, onLoadMore])
+
+ // 컴팩트 모드를 위한 클래스 정의
+ const compactStyles = compact ? {
+ row: "h-7",
+ cell: "py-1 px-2 text-sm",
+ groupRow: "py-1 bg-muted/20 text-sm",
+ emptyRow: "h-16",
+ } : {
+ row: "",
+ cell: "",
+ groupRow: "bg-muted/20",
+ emptyRow: "h-24",
+ }
+
+ return (
+ <div className={cn("w-full space-y-2.5 overflow-auto", className)} {...props}>
+ {children}
+
+ {/* 총 개수 표시 */}
+ {totalCount !== null && (
+ <div className="text-sm text-muted-foreground">
+ 총 {totalCount.toLocaleString()}개 항목
+ {table.getRowModel().rows.length > 0 && (
+ <span className="ml-2">
+ (현재 {table.getRowModel().rows.length.toLocaleString()}개 로드됨)
+ </span>
+ )}
+ </div>
+ )}
+
+ <div
+ className="max-w-[100vw] overflow-auto"
+ style={{ maxHeight: maxHeight || '35rem' }}
+ >
+ <Table className="[&>thead]:sticky [&>thead]:top-0 [&>thead]:z-10 table-fixed">
+ {/* 테이블 헤더 */}
+ <TableHeader>
+ {table.getHeaderGroups().map((headerGroup) => (
+ <TableRow key={headerGroup.id} className={compact ? "h-8" : ""}>
+ {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={compact ? "py-1 px-2 text-sm" : ""}
+ style={{
+ ...getCommonPinningStyles({ column: header.column }),
+ width: header.getSize(),
+ }}
+ >
+ <div style={{ position: "relative" }}>
+ {header.isPlaceholder
+ ? null
+ : flexRender(
+ header.column.columnDef.header,
+ header.getContext()
+ )}
+
+ {header.column.getCanResize() && (
+ <DataTableResizer header={header} />
+ )}
+ </div>
+ </TableHead>
+ )
+ })}
+ </TableRow>
+ ))}
+ </TableHeader>
+
+ {/* 테이블 바디 */}
+ <TableBody>
+ {table.getRowModel().rows?.length ? (
+ <>
+ {table.getRowModel().rows.map((row) => {
+ // 그룹핑 헤더 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}
+ 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"
+ style={{
+ marginLeft: `${row.depth * 1.5}rem`,
+ }}
+ >
+ {row.getIsExpanded() ? (
+ <ChevronUp size={compact ? 14 : 16} />
+ ) : (
+ <ChevronRight size={compact ? 14 : 16} />
+ )}
+ </button>
+ )}
+
+ <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>
+ )
+ }
+
+ // 일반 Row
+ return (
+ <TableRow
+ key={row.id}
+ className={compactStyles.row}
+ data-state={row.getIsSelected() && "selected"}
+ >
+ {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>
+ )
+ })}
+ </>
+ ) : isEmpty ? (
+ // 데이터가 없을 때
+ <TableRow>
+ <TableCell
+ colSpan={table.getAllColumns().length}
+ className={compactStyles.emptyRow + " text-center"}
+ >
+ No results.
+ </TableCell>
+ </TableRow>
+ ) : null}
+ </TableBody>
+ </Table>
+ </div>
+
+ {/* 무한 스크롤 로딩 영역 */}
+ <div className="flex flex-col items-center space-y-4 py-4">
+ {hasNextPage && (
+ <>
+ {/* Intersection Observer 타겟 */}
+ <div ref={loadMoreRef} className="h-1" />
+
+ {isLoadingMore && (
+ <div className="flex items-center space-x-2">
+ <Loader2 className="h-4 w-4 animate-spin" />
+ <span className="text-sm text-muted-foreground">
+ 로딩 중...
+ </span>
+ </div>
+ )}
+
+ {/* 수동 로드 버튼 (자동 로딩 실패 시 대안) */}
+ {!isLoadingMore && onLoadMore && (
+ <Button
+ variant="outline"
+ onClick={onLoadMore}
+ className="w-full max-w-md"
+ >
+ 더 보기
+ </Button>
+ )}
+ </>
+ )}
+
+ {!hasNextPage && table.getRowModel().rows.length > 0 && (
+ <p className="text-sm text-muted-foreground">
+ 모든 데이터를 불러왔습니다.
+ </p>
+ )}
+ </div>
+
+ <div className="flex flex-col gap-2.5">
+ {/* 선택된 행 정보 */}
+ {table.getFilteredSelectedRowModel().rows.length > 0 && (
+ <div className="text-sm text-muted-foreground">
+ {table.getFilteredSelectedRowModel().rows.length} of{" "}
+ {table.getRowModel().rows.length} row(s) selected.
+ </div>
+ )}
+
+ {/* Floating Bar (선택된 행 있을 때) */}
+ {table.getFilteredSelectedRowModel().rows.length > 0 && floatingBar}
+ </div>
+ </div>
+ )
+} \ No newline at end of file
diff --git a/components/form-data/form-data-table-columns.tsx b/components/form-data/form-data-table-columns.tsx
index de479efb..b088276e 100644
--- a/components/form-data/form-data-table-columns.tsx
+++ b/components/form-data/form-data-table-columns.tsx
@@ -57,6 +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[]>; // 새로 추가
}
/**
@@ -72,6 +73,7 @@ export function getColumns<TData extends object>({
tempCount,
selectedRows = {},
onRowSelectionChange,
+ editableFieldsMap = new Map(), // 새로 추가
}: GetColumnsProps<TData>): ColumnDef<TData>[] {
const columns: ColumnDef<TData>[] = [];
@@ -139,42 +141,64 @@ export function getColumns<TData extends object>({
minWidth: 80,
paddingFactor: 1.2,
maxWidth: col.key === "TAG_NO" ? 120 : 150,
- isReadOnly: col.shi === true, // shi 정보를 메타데이터에 저장
+ isReadOnly: col.shi === true,
},
- // (3) 실제 셀(cell) 렌더링: type에 따라 분기 가능
+
cell: ({ row }) => {
const cellValue = row.getValue(col.key);
- // shi 속성이 true인 경우 적용할 스타일
- const isReadOnly = col.shi === true;
- const readOnlyClass = isReadOnly ? "read-only-cell" : "";
+ // 기본 읽기 전용 여부 (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;
+ }
+ }
+
+ 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 클래스에서는 편집할 수 없는 필드입니다";
+ }
+ }
+
// 데이터 타입별 처리
switch (col.type) {
case "NUMBER":
- // 예: number인 경우 콤마 등 표시
return (
<div
className={readOnlyClass}
style={cellStyle}
- title={isReadOnly ? "읽기 전용 필드입니다" : ""}
+ title={tooltipMessage}
>
{cellValue ? Number(cellValue).toLocaleString() : ""}
</div>
);
case "LIST":
- // 예: select인 경우 label만 표시
return (
<div
className={readOnlyClass}
style={cellStyle}
- title={isReadOnly ? "읽기 전용 필드입니다" : ""}
+ title={tooltipMessage}
>
{String(cellValue ?? "")}
</div>
@@ -186,7 +210,7 @@ export function getColumns<TData extends object>({
<div
className={readOnlyClass}
style={cellStyle}
- title={isReadOnly ? "읽기 전용 필드입니다" : ""}
+ title={tooltipMessage}
>
{String(cellValue ?? "")}
</div>
@@ -196,7 +220,6 @@ export function getColumns<TData extends object>({
}));
columns.push(...baseColumns);
-
// (4) 액션 칼럼 - update 버튼 예시
const actionColumn: ColumnDef<TData> = {
id: "update",
diff --git a/components/form-data/form-data-table.tsx b/components/form-data/form-data-table.tsx
index 0a76e145..6de6dd0b 100644
--- a/components/form-data/form-data-table.tsx
+++ b/components/form-data/form-data-table.tsx
@@ -110,6 +110,7 @@ export interface DynamicTableProps {
formName?: string;
objectCode?: string;
mode: "IM" | "ENG"; // 모드 속성
+ editableFieldsMap?: Map<string, string[]>; // 새로 추가
}
export default function DynamicTable({
@@ -121,6 +122,7 @@ export default function DynamicTable({
projectId,
mode = "IM", // 기본값 설정
formName = `${formCode}`, // Default form name based on formCode
+ editableFieldsMap = new Map(), // 새로 추가
}: DynamicTableProps) {
const params = useParams();
const router = useRouter();
@@ -230,7 +232,8 @@ export default function DynamicTable({
setReportData,
tempCount,
selectedRows,
- onRowSelectionChange: setSelectedRows
+ onRowSelectionChange: setSelectedRows,
+ editableFieldsMap
}),
[columnsJSON, setRowAction, setReportData, tempCount, selectedRows]
);
@@ -397,26 +400,30 @@ export default function DynamicTable({
async function handleImportExcel(e: React.ChangeEvent<HTMLInputElement>) {
const file = e.target.files?.[0];
if (!file) return;
-
+
try {
setIsImporting(true);
- // Call the updated importExcelData function with direct save capability
+ // Call the updated importExcelData function with editableFieldsMap
const result = await importExcelData({
file,
tableData,
columnsJSON,
- formCode, // Pass formCode for direct save
- contractItemId, // Pass contractItemId for direct save
+ formCode,
+ contractItemId,
+ editableFieldsMap, // 추가: 편집 가능 필드 정보 전달
onPendingChange: setIsImporting,
onDataUpdate: (newData) => {
- // This is called only after successful DB save
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();
}
} catch (error) {
@@ -428,7 +435,6 @@ export default function DynamicTable({
setIsImporting(false);
}
}
-
// SEDP Send handler (with confirmation)
function handleSEDPSendClick() {
if (tableData.length === 0) {
diff --git a/components/form-data/import-excel-form.tsx b/components/form-data/import-excel-form.tsx
index d425a909..f32e44d8 100644
--- a/components/form-data/import-excel-form.tsx
+++ b/components/form-data/import-excel-form.tsx
@@ -1,17 +1,18 @@
-// lib/excelUtils.ts (continued)
import ExcelJS from "exceljs";
import { saveAs } from "file-saver";
import { toast } from "sonner";
import { DataTableColumnJSON } from "./form-data-table-columns";
import { updateFormDataInDB } from "@/lib/forms/services";
import { decryptWithServerAction } from "../drm/drmUtils";
-// Assuming the previous types are defined above
+
+// Enhanced options interface with editableFieldsMap
export interface ImportExcelOptions {
file: File;
tableData: GenericData[];
columnsJSON: DataTableColumnJSON[];
- formCode?: string; // Optional - provide to enable direct DB save
- contractItemId?: number; // Optional - provide to enable direct DB save
+ formCode?: string;
+ contractItemId?: number;
+ editableFieldsMap?: Map<string, string[]>; // 새로 추가
onPendingChange?: (isPending: boolean) => void;
onDataUpdate?: (updater: ((prev: GenericData[]) => GenericData[]) | GenericData[]) => void;
}
@@ -21,6 +22,7 @@ export interface ImportExcelResult {
importedCount?: number;
error?: any;
message?: string;
+ skippedFields?: { tagNo: string, fields: string[] }[]; // 건너뛴 필드 정보
}
export interface ExportExcelOptions {
@@ -30,7 +32,6 @@ export interface ExportExcelOptions {
onPendingChange?: (isPending: boolean) => void;
}
-// For typing consistency
interface GenericData {
[key: string]: any;
}
@@ -41,6 +42,7 @@ export async function importExcelData({
columnsJSON,
formCode,
contractItemId,
+ editableFieldsMap = new Map(), // 기본값으로 빈 Map
onPendingChange,
onDataUpdate
}: ImportExcelOptions): Promise<ImportExcelResult> {
@@ -59,7 +61,6 @@ export async function importExcelData({
});
const workbook = new ExcelJS.Workbook();
- // const arrayBuffer = await file.arrayBuffer();
const arrayBuffer = await decryptWithServerAction(file);
await workbook.xlsx.load(arrayBuffer);
@@ -127,6 +128,7 @@ export async function importExcelData({
const importedData: GenericData[] = [];
const lastRowNumber = worksheet.lastRow?.number || 1;
let errorCount = 0;
+ const skippedFieldsLog: { tagNo: string, fields: string[] }[] = []; // 건너뛴 필드 로그
// Process each data row
for (let rowNum = 2; rowNum <= lastRowNumber; rowNum++) {
@@ -135,21 +137,51 @@ export async function importExcelData({
if (!rowValues || rowValues.length <= 1) continue; // Skip empty rows
let errorMessage = "";
+ let warningMessage = "";
const rowObj: Record<string, any> = {};
+ const skippedFields: string[] = []; // 현재 행에서 건너뛴 필드들
- // Get the TAG_NO first to identify existing data
+ // Get the TAG_NO first to identify existing data and editable fields
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;
- // Check if this column should be ignored (col.shi === true)
+ // Determine if this field is editable
+ let isFieldEditable = true;
+ let skipReason = "";
+
+ // 1. Check if this is a SHI-only field
if (col.shi === true) {
- // Use existing value instead of Excel value
+ 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 {
@@ -165,9 +197,13 @@ export async function importExcelData({
break;
}
}
+
+ // Log skipped field
+ skippedFields.push(`${col.label} (${skipReason})`);
return; // Skip processing Excel value for this column
}
+ // Process Excel value for editable fields
const cellValue = rowValues[colIndex] ?? "";
let stringVal = String(cellValue).trim();
@@ -212,6 +248,15 @@ export async function importExcelData({
}
});
+ // Log skipped fields for this TAG
+ if (skippedFields.length > 0) {
+ skippedFieldsLog.push({
+ tagNo: tagNo,
+ fields: skippedFields
+ });
+ warningMessage += `Skipped ${skippedFields.length} non-editable fields. `;
+ }
+
// Validate TAG_NO
const tagNum = rowObj["TAG_NO"];
if (!tagNum) {
@@ -225,10 +270,23 @@ export async function importExcelData({
row.getCell(lastColIndex).value = errorMessage.trim();
errorCount++;
} else {
+ // Add warning message to Excel if there are skipped fields
+ if (warningMessage) {
+ row.getCell(lastColIndex).value = `WARNING: ${warningMessage.trim()}`;
+ }
importedData.push(rowObj);
}
}
+ // Show summary of skipped fields
+ if (skippedFieldsLog.length > 0) {
+ 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.`
+ );
+ }
+
// If there are validation errors, download error report and exit
if (errorCount > 0) {
const outBuffer = await workbook.xlsx.writeBuffer();
@@ -236,7 +294,11 @@ export async function importExcelData({
toast.error(
`There are ${errorCount} error row(s). Please check downloaded file.`
);
- return { success: false, error: "Data validation errors" };
+ return {
+ success: false,
+ error: "Data validation errors",
+ skippedFields: skippedFieldsLog
+ };
}
// If we reached here, all data is valid
@@ -310,13 +372,15 @@ export async function importExcelData({
return {
success: true,
importedCount: successCount,
- message: `Partially successful: ${successCount} rows updated, ${errorCount} errors`
+ message: `Partially successful: ${successCount} rows updated, ${errorCount} errors`,
+ skippedFields: skippedFieldsLog
};
} else {
return {
success: false,
error: "All updates failed",
- message: errors.join("\n")
+ message: errors.join("\n"),
+ skippedFields: skippedFieldsLog
};
}
}
@@ -326,16 +390,25 @@ export async function importExcelData({
onDataUpdate(() => mergedData);
}
- toast.success(`Successfully updated ${successCount} rows`);
+ const successMessage = skippedFieldsLog.length > 0
+ ? `Successfully updated ${successCount} rows (some non-editable fields were preserved)`
+ : `Successfully updated ${successCount} rows`;
+
+ toast.success(successMessage);
return {
success: true,
importedCount: successCount,
- message: "All data imported and saved to database"
+ message: "All data imported and saved to database",
+ skippedFields: skippedFieldsLog
};
} catch (saveError) {
console.error("Failed to save imported data:", saveError);
toast.error("Failed to save imported data to database");
- return { success: false, error: saveError };
+ return {
+ success: false,
+ error: saveError,
+ skippedFields: skippedFieldsLog
+ };
}
} else {
// Fall back to just updating local state if DB parameters aren't provided
@@ -343,8 +416,16 @@ export async function importExcelData({
onDataUpdate(() => mergedData);
}
- toast.success(`Imported ${importedData.length} rows successfully (local only)`);
- return { success: true, importedCount: importedData.length };
+ const successMessage = skippedFieldsLog.length > 0
+ ? `Imported ${importedData.length} rows successfully (some fields preserved)`
+ : `Imported ${importedData.length} rows successfully`;
+
+ toast.success(`${successMessage} (local only)`);
+ return {
+ success: true,
+ importedCount: importedData.length,
+ skippedFields: skippedFieldsLog
+ };
}
} catch (err) {
@@ -354,4 +435,4 @@ export async function importExcelData({
} finally {
if (onPendingChange) onPendingChange(false);
}
-} \ No newline at end of file
+}