diff options
Diffstat (limited to 'components')
| -rw-r--r-- | components/data-table/data-table-pagination.tsx | 219 | ||||
| -rw-r--r-- | components/data-table/infinite-data-table.tsx | 294 | ||||
| -rw-r--r-- | components/form-data/form-data-table-columns.tsx | 47 | ||||
| -rw-r--r-- | components/form-data/form-data-table.tsx | 20 | ||||
| -rw-r--r-- | components/form-data/import-excel-form.tsx | 117 |
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 +} |
