diff options
| author | dujinkim <dujin.kim@dtsolution.co.kr> | 2025-06-02 02:27:28 +0000 |
|---|---|---|
| committer | dujinkim <dujin.kim@dtsolution.co.kr> | 2025-06-02 02:27:28 +0000 |
| commit | 37611339fea096e47aaa42311a13a6313b4200db (patch) | |
| tree | dd9c7dba27a3db2aebd18bf2087c6a30987aa957 /components | |
| parent | bac0228d21b7195065e9cddcc327ae33659c7bcc (diff) | |
(대표님) 20250602 오전 작업사항 (코드프리징)
Diffstat (limited to 'components')
| -rw-r--r-- | components/data-table/data-table.tsx | 5 | ||||
| -rw-r--r-- | components/data-table/expandable-data-table.tsx | 226 | ||||
| -rw-r--r-- | components/form-data/export-excel-form.tsx | 321 | ||||
| -rw-r--r-- | components/form-data/form-data-table-columns.tsx | 47 | ||||
| -rw-r--r-- | components/form-data/import-excel-form.tsx | 284 | ||||
| -rw-r--r-- | components/form-data/sedp-compare-dialog.tsx | 2 |
6 files changed, 725 insertions, 160 deletions
diff --git a/components/data-table/data-table.tsx b/components/data-table/data-table.tsx index 7d376b65..64afcb7e 100644 --- a/components/data-table/data-table.tsx +++ b/components/data-table/data-table.tsx @@ -75,7 +75,10 @@ export function DataTable<TData>({ data-column-id={header.column.id} className={compact ? "py-1 px-2 text-sm" : ""} style={{ - ...getCommonPinningStylesWithBorder({ column: header.column }), + ...getCommonPinningStylesWithBorder({ + column: header.column, + isHeader: true + }), width: header.getSize(), }} > diff --git a/components/data-table/expandable-data-table.tsx b/components/data-table/expandable-data-table.tsx index 112e9448..15c59540 100644 --- a/components/data-table/expandable-data-table.tsx +++ b/components/data-table/expandable-data-table.tsx @@ -29,8 +29,11 @@ interface ExpandableDataTableProps<TData> extends React.HTMLAttributes<HTMLDivEl expandable?: boolean maxHeight?: string | number expandedRowClassName?: string - debug?: boolean // 디버깅 옵션 추가 - simpleExpansion?: boolean // 🎯 간단한 확장 방식 선택 옵션 추가 + debug?: boolean + simpleExpansion?: boolean + // ✅ 새로운 props 추가 + clickableColumns?: string[] // 클릭 가능한 컬럼 ID들 + excludeFromClick?: string[] // 클릭에서 제외할 컬럼 ID들 } export function ExpandableDataTable<TData>({ @@ -45,7 +48,10 @@ export function ExpandableDataTable<TData>({ maxHeight, expandedRowClassName, debug = false, - simpleExpansion = false, // 🎯 기본값 false (전체 확장 방식) + simpleExpansion = false, + // ✅ 새로운 props + clickableColumns, + excludeFromClick = ['actions'], children, className, ...props @@ -54,20 +60,19 @@ export function ExpandableDataTable<TData>({ useAutoSizeColumns(table, autoSizeColumns) const containerRef = React.useRef<HTMLDivElement>(null) - const scrollRef = React.useRef<HTMLDivElement>(null) // 🎯 스크롤 컨테이너 ref 추가 + const scrollRef = React.useRef<HTMLDivElement>(null) const [expandedHeights, setExpandedHeights] = React.useState<Map<string, number>>(new Map()) const [isScrolled, setIsScrolled] = React.useState(false) - const [isScrolledToEnd, setIsScrolledToEnd] = React.useState(true) // 🎯 초기값을 true로 설정 (아직 계산 안됨) - const [isInitialized, setIsInitialized] = React.useState(false) // 🎯 초기화 상태 추가 + const [isScrolledToEnd, setIsScrolledToEnd] = React.useState(true) + const [isInitialized, setIsInitialized] = React.useState(false) const [containerWidth, setContainerWidth] = React.useState<number>(0) - // 🎯 컨테이너 너비 감지 (패딩 제외된 실제 사용 가능한 너비) + // 컨테이너 너비 감지 React.useEffect(() => { if (!containerRef.current) return const updateContainerWidth = () => { if (containerRef.current) { - // clientWidth는 패딩을 제외한 실제 사용 가능한 너비 setContainerWidth(containerRef.current.clientWidth) } } @@ -75,13 +80,12 @@ export function ExpandableDataTable<TData>({ const resizeObserver = new ResizeObserver(updateContainerWidth) resizeObserver.observe(containerRef.current) - // 초기 너비 설정 updateContainerWidth() return () => resizeObserver.disconnect() }, []) - // 🎯 초기 스크롤 상태 체크 (더 확실한 초기화) + // 초기 스크롤 상태 체크 React.useEffect(() => { if (!scrollRef.current) return @@ -108,15 +112,12 @@ export function ExpandableDataTable<TData>({ } } - // 즉시 체크 checkScrollState() - // 짧은 지연 후 재체크 (테이블 렌더링 완료 대기) const timeouts = [50, 100, 200].map(delay => setTimeout(checkScrollState, delay) ) - // ResizeObserver로 테이블 크기 변화 감지 const resizeObserver = new ResizeObserver(() => { setTimeout(checkScrollState, 10) }) @@ -128,24 +129,28 @@ export function ExpandableDataTable<TData>({ } }, [table, debug]) - // 🎯 데이터 변경 시 스크롤 상태 재설정 + // 데이터 변경 시 스크롤 상태 재설정 React.useEffect(() => { - setIsInitialized(false) // 🎯 데이터 변경 시 초기화 상태 리셋 - setIsScrolledToEnd(true) // 🎯 안전한 기본값으로 리셋 - }, [table.getRowModel().rows.length]) + setIsInitialized(false) + setIsScrolledToEnd(true) + + if (debug) { + const allColumns = table.getAllColumns().map(col => col.id) + const visibleColumns = table.getVisibleFlatColumns().map(col => col.id) + console.log('🔍 Available columns:', { allColumns, visibleColumns, clickableColumns, excludeFromClick }) + } + }, [table.getRowModel().rows.length, clickableColumns, excludeFromClick, debug]) const handleScroll = (e: React.UIEvent<HTMLDivElement>) => { const scrollLeft = e.currentTarget.scrollLeft const scrollWidth = e.currentTarget.scrollWidth const clientWidth = e.currentTarget.clientWidth - // 🎯 왼쪽으로부터 스크롤 상태 (왼쪽 핀용) setIsScrolled(scrollLeft > 0) - // 🎯 오른쪽 끝까지 스크롤 상태 (오른쪽 핀용) - const isAtEnd = Math.abs(scrollWidth - clientWidth - scrollLeft) < 1 // 1px 오차 허용 + const isAtEnd = Math.abs(scrollWidth - clientWidth - scrollLeft) < 1 setIsScrolledToEnd(isAtEnd) - setIsInitialized(true) // 🎯 스크롤 이벤트 발생 시 초기화 완료 + setIsInitialized(true) if (debug) { console.log('Scroll state:', { @@ -159,7 +164,77 @@ export function ExpandableDataTable<TData>({ } } - // 🔧 개선된 핀 스타일 함수 (좌/우 핀 구분 처리) + // 행 확장/축소 핸들러 + const toggleRowExpansion = React.useCallback((rowId: string, event?: React.MouseEvent) => { + if (!setExpandedRows) return + + if (event) { + event.stopPropagation() + } + + const newExpanded = new Set(expandedRows) + if (newExpanded.has(rowId)) { + newExpanded.delete(rowId) + setExpandedHeights(prev => { + const newHeights = new Map(prev) + newHeights.delete(rowId) + return newHeights + }) + } else { + newExpanded.add(rowId) + } + setExpandedRows(newExpanded) + }, [expandedRows, setExpandedRows]) + + // ✅ 셀이 클릭 가능한지 확인하는 함수 + const isCellClickable = React.useCallback((columnId: string) => { + if (!expandable || !setExpandedRows) return false + + if (excludeFromClick.includes(columnId)) { + if (debug) console.log('🚫 Column excluded:', columnId) + return false + } + + if (clickableColumns && clickableColumns.length > 0) { + const isClickable = clickableColumns.includes(columnId) + if (debug) console.log('📋 Column clickable check:', { columnId, clickableColumns, isClickable }) + return isClickable + } + + if (debug) console.log('✅ Column clickable by default:', columnId) + return true + }, [expandable, setExpandedRows, clickableColumns, excludeFromClick, debug]) + + // ✅ 셀 클릭 핸들러 + const handleCellClick = React.useCallback((rowId: string, columnId: string, event: React.MouseEvent) => { + if (debug) console.log('🔍 Cell clicked:', { rowId, columnId, clickable: isCellClickable(columnId) }) + + if (!isCellClickable(columnId)) { + if (debug) console.log('❌ Cell not clickable:', columnId) + return + } + + const target = event.target as HTMLElement + + // 실제 BUTTON과 A 태그만 제외 + if (target.tagName === 'BUTTON' || target.tagName === 'A') { + if (debug) console.log('❌ Button or link clicked, ignoring') + return + } + + if (debug) console.log('✅ Toggling row expansion for:', rowId) + toggleRowExpansion(rowId, event) + }, [isCellClickable, toggleRowExpansion, debug]) + + // 키보드 네비게이션 핸들러 + const handleKeyDown = React.useCallback((event: React.KeyboardEvent, rowId: string) => { + if (event.key === 'Enter' || event.key === ' ') { + event.preventDefault() + toggleRowExpansion(rowId) + } + }, [toggleRowExpansion]) + + // 핀 스타일 함수 const getPinnedStyle = React.useCallback((column: any, isHeader: boolean = false) => { if (debug) { debugPinningInfo(column) @@ -174,9 +249,7 @@ export function ExpandableDataTable<TData>({ const pinnedSide = column.getIsPinned() if (!pinnedSide) { - // width를 제외한 나머지 스타일만 반환 const { width, ...restStyle } = baseStyle - // 헤더인 경우 핀되지 않았어도 배경 필요 (sticky 때문에) return { ...restStyle, ...(isHeader && { @@ -186,10 +259,9 @@ export function ExpandableDataTable<TData>({ } } - // 확장 버튼이 있을 때 left pin된 컬럼들을 오른쪽으로 이동 let leftPosition = baseStyle.left if (expandable && pinnedSide === "left") { - const expandButtonWidth = 40 // w-10 = 40px + const expandButtonWidth = 40 if (typeof baseStyle.left === 'string') { const currentLeft = parseFloat(baseStyle.left.replace('px', '')) leftPosition = `${currentLeft + expandButtonWidth}px` @@ -200,23 +272,17 @@ export function ExpandableDataTable<TData>({ } } - // 🎯 핀 위치에 따른 배경 결정 let shouldShowBackground = false if (isHeader) { - // 헤더는 항상 배경 표시 shouldShowBackground = true } else { - // 바디 셀의 경우 핀 위치에 따라 다른 조건 적용 if (pinnedSide === "left") { - // 왼쪽 핀: 오른쪽으로 스크롤했을 때 배경 표시 shouldShowBackground = isScrolled } else if (pinnedSide === "right") { - // 오른쪽 핀: 초기화 전이거나 오른쪽 끝까지 스크롤하지 않았을 때 배경 표시 shouldShowBackground = !isInitialized || !isScrolledToEnd } } - // width를 제외한 스타일 적용 const { width, ...restBaseStyle } = baseStyle const finalStyle = { @@ -244,7 +310,6 @@ export function ExpandableDataTable<TData>({ return finalStyle } catch (error) { console.error("Error in getPinnedStyle:", error) - // fallback 스타일 return { position: 'relative' as const, ...(isHeader && { @@ -254,7 +319,7 @@ export function ExpandableDataTable<TData>({ } }, [expandable, isScrolled, isScrolledToEnd, isInitialized, debug]) - // 확장 버튼용 스타일 (안정성 개선) + // 확장 버튼용 스타일 const getExpandButtonStyle = React.useCallback(() => { return { position: 'sticky' as const, @@ -267,50 +332,19 @@ export function ExpandableDataTable<TData>({ } }, []) - // 🎯 테이블 총 너비 계산 + // 테이블 총 너비 계산 const getTableWidth = React.useCallback(() => { const expandButtonWidth = expandable ? 40 : 0 const totalSize = table.getCenterTotalSize() + table.getLeftTotalSize() + table.getRightTotalSize() - return Math.max(totalSize + expandButtonWidth, 800) // 최소 800px 보장 + return Math.max(totalSize + expandButtonWidth, 800) }, [table, expandable]) - // 행 확장/축소 핸들러 - const toggleRowExpansion = React.useCallback((rowId: string, event?: React.MouseEvent) => { - if (!setExpandedRows) return - - if (event) { - event.stopPropagation() - } - - const newExpanded = new Set(expandedRows) - if (newExpanded.has(rowId)) { - newExpanded.delete(rowId) - setExpandedHeights(prev => { - const newHeights = new Map(prev) - newHeights.delete(rowId) - return newHeights - }) - } else { - newExpanded.add(rowId) - } - setExpandedRows(newExpanded) - }, [expandedRows, setExpandedRows]) - - // 키보드 네비게이션 핸들러 - const handleKeyDown = React.useCallback((event: React.KeyboardEvent, rowId: string) => { - if (event.key === 'Enter' || event.key === ' ') { - event.preventDefault() - toggleRowExpansion(rowId) - } - }, [toggleRowExpansion]) - - // 🎯 확장된 내용 스타일 계산 함수 (컨테이너 너비 기준) + // 확장된 내용 스타일 계산 const getExpandedContentStyle = React.useCallback(() => { const expandButtonWidth = expandable ? 40 : 0 - const availableWidth = containerWidth || 800 // 🎯 컨테이너 너비 사용 (fallback 800px) - const contentWidth = availableWidth - expandButtonWidth - 32 // 버튼 + 여백(16px * 2) 제외 + const availableWidth = containerWidth || 800 + const contentWidth = availableWidth - expandButtonWidth - 32 - // 🎯 디버그 정보 if (debug) { console.log('Expanded content sizing:', { containerWidth, @@ -322,8 +356,8 @@ export function ExpandableDataTable<TData>({ } return { - width: `${Math.max(contentWidth, 300)}px`, // 🎯 최소 300px 보장 - marginLeft: `${expandButtonWidth + 8}px`, // 🎯 확장 버튼 + 여백 + width: `${Math.max(contentWidth, 300)}px`, + marginLeft: `${expandButtonWidth + 8}px`, marginRight: '16px', padding: '12px 16px', backgroundColor: 'hsl(var(--background))', @@ -332,16 +366,16 @@ export function ExpandableDataTable<TData>({ border: '1px solid hsl(var(--border))', borderTop: 'none', marginBottom: '4px', - maxWidth: `${availableWidth - expandButtonWidth - 16}px`, // 🎯 컨테이너 너비 초과 방지 + maxWidth: `${availableWidth - expandButtonWidth - 16}px`, overflow: 'auto', } }, [expandable, containerWidth, debug]) - // 🎯 간단한 확장 스타일 (컨테이너 너비 기준) + // 간단한 확장 스타일 const getSimpleExpandedStyle = React.useCallback(() => { const expandButtonWidth = expandable ? 40 : 0 const availableWidth = containerWidth || 800 - const contentWidth = availableWidth - expandButtonWidth - 48 // 버튼 + 여백 제외 + const contentWidth = availableWidth - expandButtonWidth - 48 return { marginLeft: `${expandButtonWidth + 8}px`, @@ -358,12 +392,12 @@ export function ExpandableDataTable<TData>({ } }, [expandable, containerWidth]) - // 🎯 사용할 확장 스타일 선택 (props로 제어) const useSimpleExpansion = simpleExpansion const getExpandedPlaceholderHeight = React.useCallback((contentHeight: number) => { - const padding = 24 + 4 // padding (12px * 2) + marginBottom (4px) + const padding = 24 + 4 return Math.max(contentHeight + padding, 220) }, []) + const updateExpandedHeight = React.useCallback((rowId: string, height: number) => { setExpandedHeights(prev => { if (prev.get(rowId) !== height) { @@ -453,22 +487,22 @@ export function ExpandableDataTable<TData>({ {children} <div - ref={containerRef} // 🎯 컨테이너 wrapper ref (패딩 제외 너비 계산용) + ref={containerRef} className="relative rounded-md border" style={{ minHeight: '200px' }} > <div - ref={scrollRef} // 🎯 스크롤 컨테이너 ref 연결 + ref={scrollRef} className="overflow-auto" style={{ maxHeight: maxHeight || '34rem' }} - onScroll={handleScroll} // 🎯 스크롤 이벤트 핸들러 + onScroll={handleScroll} > <Table className="[&>thead]:sticky [&>thead]:top-0 [&>thead]:z-10" style={{ - width: getTableWidth(), // 🎯 동적 너비 계산 + width: getTableWidth(), minWidth: '100%' }} > @@ -497,8 +531,8 @@ export function ExpandableDataTable<TData>({ "bg-background" )} style={{ - ...getPinnedStyle(header.column, true), // 🎯 동적 스타일 - width: header.getSize() // 🎯 width 별도 설정 + ...getPinnedStyle(header.column, true), + width: header.getSize() }} > <div style={{ position: "relative" }}> @@ -599,15 +633,33 @@ export function ExpandableDataTable<TData>({ return null } + const isClickable = isCellClickable(cell.column.id) + return ( <TableCell key={cell.id} data-column-id={cell.column.id} - className={compactStyles.cell} + className={cn( + compactStyles.cell, + // ✅ 클릭 가능한 셀에 시각적 피드백 추가 + isClickable && "cursor-pointer hover:bg-muted/50 transition-colors" + )} style={{ - ...getPinnedStyle(cell.column, false), // 🎯 동적 스타일 - width: cell.column.getSize() // 🎯 width 별도 설정 + ...getPinnedStyle(cell.column, false), + width: cell.column.getSize() }} + // ✅ 클릭 이벤트 추가 + onClick={isClickable ? (e) => handleCellClick(row.id, cell.column.id, e) : undefined} + // ✅ 접근성 개선 + role={isClickable ? "button" : undefined} + tabIndex={isClickable ? 0 : undefined} + onKeyDown={isClickable ? (e) => { + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault() + handleCellClick(row.id, cell.column.id, e as any) + } + } : undefined} + aria-label={isClickable ? `${isExpanded ? '행 축소' : '행 확장'} - ${cell.column.id}` : undefined} > {flexRender( cell.column.columnDef.cell, @@ -632,14 +684,12 @@ export function ExpandableDataTable<TData>({ }} > {useSimpleExpansion ? ( - // 🎯 간단한 확장 방식: 테이블 내부에서만 확장 <div style={getSimpleExpandedStyle()}> <ExpandedContentWrapper rowId={row.id}> {renderExpandedContent(row.original)} </ExpandedContentWrapper> </div> ) : ( - // 🎯 전체 화면 확장 방식: 테이블 너비 기준으로 개선 <div className="relative w-full"> <div className="absolute top-0" diff --git a/components/form-data/export-excel-form.tsx b/components/form-data/export-excel-form.tsx index d0ccf980..07d3c447 100644 --- a/components/form-data/export-excel-form.tsx +++ b/components/form-data/export-excel-form.tsx @@ -13,6 +13,7 @@ export interface DataTableColumnJSON { type: ColumnType; options?: string[]; shi?: boolean; // SHI-only field indicator + required?: boolean; // Required field indicator // Add any other properties that might be in columnsJSON } @@ -22,18 +23,249 @@ export interface GenericData { TAG_NO?: string; // Since TAG_NO seems important in the code } +// Define error structure +export interface DataError { + tagNo: string; + rowIndex: number; + columnKey: string; + columnLabel: string; + errorType: string; + errorMessage: string; + currentValue?: any; + expectedFormat?: string; +} + // Define the options interface for the export function export interface ExportExcelOptions { tableData: GenericData[]; columnsJSON: DataTableColumnJSON[]; formCode: string; onPendingChange?: (isPending: boolean) => void; + validateData?: boolean; // Option to enable/disable data validation } // Define the return type export interface ExportExcelResult { success: boolean; error?: any; + errorCount?: number; + hasErrors?: boolean; +} + +/** + * Validate data and collect errors + */ +function validateTableData( + tableData: GenericData[], + columnsJSON: DataTableColumnJSON[] +): DataError[] { + const errors: DataError[] = []; + const tagNoSet = new Set<string>(); + + tableData.forEach((rowData, index) => { + const rowIndex = index + 2; // Excel row number (header is row 1) + const tagNo = rowData.TAG_NO || `Row-${rowIndex}`; + + // Check for duplicate TAG_NO + if (rowData.TAG_NO) { + if (tagNoSet.has(rowData.TAG_NO)) { + errors.push({ + tagNo, + rowIndex, + columnKey: "TAG_NO", + columnLabel: "TAG NO", + errorType: "DUPLICATE", + errorMessage: "Duplicate TAG_NO found", + currentValue: rowData.TAG_NO, + }); + } else { + tagNoSet.add(rowData.TAG_NO); + } + } + + // Validate each column + columnsJSON.forEach((column) => { + const value = rowData[column.key]; + const isEmpty = value === undefined || value === null || value === ""; + + // Required field validation + if (column.required && isEmpty) { + errors.push({ + tagNo, + rowIndex, + columnKey: column.key, + columnLabel: column.label, + errorType: "REQUIRED", + errorMessage: "Required field is empty", + currentValue: value, + }); + } + + if (!isEmpty) { + // Type validation + switch (column.type) { + case "NUMBER": + if (isNaN(Number(value))) { + errors.push({ + tagNo, + rowIndex, + columnKey: column.key, + columnLabel: column.label, + errorType: "TYPE_MISMATCH", + errorMessage: "Value is not a valid number", + currentValue: value, + expectedFormat: "Number", + }); + } + break; + + case "LIST": + if (column.options && !column.options.includes(String(value))) { + errors.push({ + tagNo, + rowIndex, + columnKey: column.key, + columnLabel: column.label, + errorType: "INVALID_OPTION", + errorMessage: "Value is not in the allowed options list", + currentValue: value, + expectedFormat: column.options.join(", "), + }); + } + break; + + case "STRING": + // Additional string validations can be added here + if (typeof value !== "string" && typeof value !== "number") { + errors.push({ + tagNo, + rowIndex, + columnKey: column.key, + columnLabel: column.label, + errorType: "TYPE_MISMATCH", + errorMessage: "Value is not a valid string", + currentValue: value, + expectedFormat: "String", + }); + } + break; + } + } + }); + }); + + return errors; +} + +/** + * Create error sheet with validation results + */ +function createErrorSheet(workbook: ExcelJS.Workbook, errors: DataError[]) { + const errorSheet = workbook.addWorksheet("Errors"); + + // Error sheet headers + const errorHeaders = [ + "TAG NO", + "Row Number", + "Column", + "Error Type", + "Error Message", + "Current Value", + "Expected Format", + ]; + + errorSheet.addRow(errorHeaders); + + // Style error sheet header + const errorHeaderRow = errorSheet.getRow(1); + errorHeaderRow.font = { bold: true, color: { argb: "FFFFFFFF" } }; + errorHeaderRow.alignment = { horizontal: "center" }; + + errorHeaderRow.eachCell((cell) => { + cell.fill = { + type: "pattern", + pattern: "solid", + fgColor: { argb: "FFDC143C" }, // Crimson background + }; + }); + + // Add error data + errors.forEach((error) => { + const errorRow = errorSheet.addRow([ + error.tagNo, + error.rowIndex, + error.columnLabel, + error.errorType, + error.errorMessage, + error.currentValue || "", + error.expectedFormat || "", + ]); + + // Color code by error type + errorRow.eachCell((cell, colNumber) => { + let bgColor = "FFFFFFFF"; // Default white + + switch (error.errorType) { + case "REQUIRED": + bgColor = "FFFFCCCC"; // Light red + break; + case "TYPE_MISMATCH": + bgColor = "FFFFEECC"; // Light orange + break; + case "INVALID_OPTION": + bgColor = "FFFFFFE0"; // Light yellow + break; + case "DUPLICATE": + bgColor = "FFFFE0E0"; // Very light red + break; + } + + cell.fill = { + type: "pattern", + pattern: "solid", + fgColor: { argb: bgColor }, + }; + }); + }); + + // Auto-fit columns + errorSheet.columns.forEach((column) => { + let maxLength = 0; + column.eachCell({ includeEmpty: false }, (cell) => { + const columnLength = String(cell.value).length; + if (columnLength > maxLength) { + maxLength = columnLength; + } + }); + column.width = Math.min(Math.max(maxLength + 2, 10), 50); + }); + + // Add summary at the top + errorSheet.insertRow(1, [`Total Errors Found: ${errors.length}`]); + const summaryRow = errorSheet.getRow(1); + summaryRow.font = { bold: true, size: 14 }; + if (errors.length > 0) { + summaryRow.getCell(1).fill = { + type: "pattern", + pattern: "solid", + fgColor: { argb: "FFFFC0C0" }, // Light red background + }; + } + + // Adjust header row number + const newHeaderRow = errorSheet.getRow(2); + newHeaderRow.font = { bold: true, color: { argb: "FFFFFFFF" } }; + newHeaderRow.alignment = { horizontal: "center" }; + + newHeaderRow.eachCell((cell) => { + cell.fill = { + type: "pattern", + pattern: "solid", + fgColor: { argb: "FFDC143C" }, + }; + }); + + return errorSheet; } /** @@ -45,11 +277,15 @@ export async function exportExcelData({ tableData, columnsJSON, formCode, - onPendingChange + onPendingChange, + validateData = true }: ExportExcelOptions): Promise<ExportExcelResult> { try { if (onPendingChange) onPendingChange(true); + // Validate data first if validation is enabled + const errors = validateData ? validateTableData(tableData, columnsJSON) : []; + // Create a new workbook const workbook = new ExcelJS.Workbook(); @@ -92,7 +328,13 @@ export async function exportExcelData({ }); // 2. 데이터 시트에 헤더 추가 - const headers = columnsJSON.map((col) => col.label); + const headers = columnsJSON.map((col) => { + let headerLabel = col.label; + if (col.required) { + headerLabel += " *"; // Required fields marked with asterisk + } + return headerLabel; + }); worksheet.addRow(headers); // 헤더 스타일 적용 @@ -106,13 +348,21 @@ export async function exportExcelData({ const column = columnsJSON[columnIndex]; if (column?.shi === true) { - // SHI-only 필드는 더 진한 음영으로 표시 (헤더 라벨은 원본 유지) + // SHI-only 필드는 더 진한 음영으로 표시 cell.fill = { type: "pattern", pattern: "solid", fgColor: { argb: "FFFF9999" }, // 연한 빨간색 배경 }; cell.font = { bold: true, color: { argb: "FF800000" } }; // 진한 빨간색 글자 + } else if (column?.required) { + // Required 필드는 파란색 배경 + cell.fill = { + type: "pattern", + pattern: "solid", + fgColor: { argb: "FFCCE5FF" }, // 연한 파란색 배경 + }; + cell.font = { bold: true, color: { argb: "FF000080" } }; // 진한 파란색 글자 } else { // 일반 필드는 기존 스타일 cell.fill = { @@ -131,20 +381,34 @@ export async function exportExcelData({ }); const dataRow = worksheet.addRow(rowValues); + // Get errors for this row + const rowErrors = errors.filter(err => err.rowIndex === rowIndex + 2); + const hasErrors = rowErrors.length > 0; + // SHI-only 컬럼의 데이터 셀에도 음영 적용 dataRow.eachCell((cell, colNumber) => { const columnIndex = colNumber - 1; const column = columnsJSON[columnIndex]; + // Check if this cell has errors + const cellHasError = rowErrors.some(err => err.columnKey === column.key); + if (column?.shi === true) { // SHI-only 필드의 데이터 셀에 연한 음영 적용 cell.fill = { type: "pattern", pattern: "solid", - fgColor: { argb: "FFFFCCCC" }, // 매우 연한 빨간색 배경 + fgColor: { argb: cellHasError ? "FFFF6666" : "FFFFCCCC" }, // 에러가 있으면 더 진한 빨간색 }; - // 읽기 전용임을 나타내기 위해 이탤릭 적용 cell.font = { italic: true, color: { argb: "FF666666" } }; + } else if (cellHasError) { + // 에러가 있는 셀은 연한 빨간색 배경 + cell.fill = { + type: "pattern", + pattern: "solid", + fgColor: { argb: "FFFFDDDD" }, + }; + cell.font = { color: { argb: "FFCC0000" } }; } }); }); @@ -162,7 +426,7 @@ export async function exportExcelData({ // 유효성 검사 정의 const validation = { type: "list" as const, - allowBlank: true, + allowBlank: !col.required, formulae: [validationRange], showErrorMessage: true, errorStyle: "warning" as const, @@ -227,15 +491,28 @@ export async function exportExcelData({ column.width = Math.min(Math.max(maxLength + 2, 10), 50); }); - // 6. 범례 추가 (별도 시트) + // 6. 에러 시트 생성 (에러가 있을 경우에만) + if (errors.length > 0) { + createErrorSheet(workbook, errors); + } + + // 7. 범례 추가 (별도 시트) 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"]); + legendSheet.addRow(["Blue background header", "Required fields (marked with *)"]); + legendSheet.addRow(["Gray background header", "Regular optional fields"]); + legendSheet.addRow(["Light red background cells", "Cells with validation errors"]); + legendSheet.addRow(["Light red data cells", "Data in SHI-only fields (read-only)"]); + + if (errors.length > 0) { + legendSheet.addRow([]); + legendSheet.addRow([`Note: ${errors.length} validation errors found in the 'Errors' sheet`]); + const errorNoteRow = legendSheet.getRow(legendSheet.rowCount); + errorNoteRow.font = { bold: true, color: { argb: "FFCC0000" } }; + } // 범례 스타일 적용 const legendHeaderRow = legendSheet.getRow(1); @@ -251,15 +528,25 @@ export async function exportExcelData({ }; }); - // 7. 파일 다운로드 + // 8. 파일 다운로드 const buffer = await workbook.xlsx.writeBuffer(); - saveAs( - new Blob([buffer]), - `${formCode}_data_${new Date().toISOString().slice(0, 10)}.xlsx` - ); + const fileName = errors.length > 0 + ? `${formCode}_data_with_errors_${new Date().toISOString().slice(0, 10)}.xlsx` + : `${formCode}_data_${new Date().toISOString().slice(0, 10)}.xlsx`; + + saveAs(new Blob([buffer]), fileName); - toast.success("Excel 내보내기 완료!"); - return { success: true }; + const message = errors.length > 0 + ? `Excel 내보내기 완료! (${errors.length}개의 검증 오류 발견)` + : "Excel 내보내기 완료!"; + + toast.success(message); + + return { + success: true, + errorCount: errors.length, + hasErrors: errors.length > 0 + }; } catch (err) { console.error("Excel export error:", err); toast.error("Excel 내보내기 실패."); diff --git a/components/form-data/form-data-table-columns.tsx b/components/form-data/form-data-table-columns.tsx index bba2a208..3749fe02 100644 --- a/components/form-data/form-data-table-columns.tsx +++ b/components/form-data/form-data-table-columns.tsx @@ -2,6 +2,7 @@ import type { ColumnDef, Row } from "@tanstack/react-table"; import { ClientDataTableColumnHeaderSimple } from "../client-data-table/data-table-column-simple-header"; import { Button } from "@/components/ui/button"; import { Checkbox } from "@/components/ui/checkbox"; +import { Badge } from "@/components/ui/badge"; // Badge import 추가 import { Ellipsis } from "lucide-react"; import { formatDate } from "@/lib/utils"; import { @@ -61,6 +62,31 @@ interface GetColumnsProps<TData> { } /** + * status 값에 따라 Badge variant를 결정하는 헬퍼 함수 + */ +function getStatusBadgeVariant(status: string): "default" | "secondary" | "destructive" | "outline" { + const statusStr = String(status).toLowerCase(); + + switch (statusStr) { + case 'NEW': + case 'New': + // case 'approved': + return 'default'; // 초록색 계열 + case 'Updated or Modified': + // case 'in progress': + // case 'processing': + return 'secondary'; // 노란색 계열 + case 'inactive': + case 'rejected': + case 'failed': + case 'cancelled': + return 'destructive'; // 빨간색 계열 + default: + return 'outline'; // 기본 회색 계열 + } +} + +/** * getColumns 함수 * 1) columnsJSON 배열을 순회하면서 accessorKey / header / cell 등을 설정 * 2) 체크박스 컬럼 추가 (showBatchSelection이 true일 때) @@ -122,8 +148,7 @@ export function getColumns<TData extends object>({ ), enableSorting: false, enableHiding: false, - enablePinning: true, // ← 이 줄 추가 - + enablePinning: true, size: 40, }; columns.push(selectColumn); @@ -160,6 +185,24 @@ export function getColumns<TData extends object>({ // 툴팁 메시지 설정 (SHI 필드만) const tooltipMessage = isReadOnly ? "SHI 전용 필드입니다" : ""; + // status 컬럼인 경우 Badge 적용 + if (col.key === "status") { + const statusValue = String(cellValue ?? ""); + const badgeVariant = getStatusBadgeVariant(statusValue); + + return ( + <div + className={readOnlyClass} + style={cellStyle} + title={tooltipMessage} + > + <Badge variant={badgeVariant}> + {statusValue} + </Badge> + </div> + ); + } + // 데이터 타입별 처리 switch (col.type) { case "NUMBER": diff --git a/components/form-data/import-excel-form.tsx b/components/form-data/import-excel-form.tsx index 6f0828b0..82c7afc8 100644 --- a/components/form-data/import-excel-form.tsx +++ b/components/form-data/import-excel-form.tsx @@ -5,6 +5,18 @@ import { DataTableColumnJSON } from "./form-data-table-columns"; import { updateFormDataInDB } from "@/lib/forms/services"; import { decryptWithServerAction } from "../drm/drmUtils"; +// Define error structure for import +export interface ImportError { + tagNo: string; + rowIndex: number; + columnKey: string; + columnLabel: string; + errorType: string; + errorMessage: string; + currentValue?: any; + expectedFormat?: string; +} + // Simplified options interface without editableFieldsMap export interface ImportExcelOptions { file: File; @@ -22,6 +34,8 @@ export interface ImportExcelResult { error?: any; message?: string; skippedFields?: { tagNo: string, fields: string[] }[]; // 건너뛴 필드 정보 + errorCount?: number; + hasErrors?: boolean; } export interface ExportExcelOptions { @@ -35,6 +49,133 @@ interface GenericData { [key: string]: any; } +/** + * Create error sheet with import validation results + */ +function createImportErrorSheet(workbook: ExcelJS.Workbook, errors: ImportError[], headerErrors?: string[]) { + const errorSheet = workbook.addWorksheet("Import_Errors"); + + // Add header error section if exists + if (headerErrors && headerErrors.length > 0) { + errorSheet.addRow(["HEADER VALIDATION ERRORS"]); + const headerErrorTitleRow = errorSheet.getRow(1); + headerErrorTitleRow.font = { bold: true, size: 14, color: { argb: "FFFFFFFF" } }; + headerErrorTitleRow.getCell(1).fill = { + type: "pattern", + pattern: "solid", + fgColor: { argb: "FFDC143C" }, + }; + + headerErrors.forEach((error, index) => { + const errorRow = errorSheet.addRow([`${index + 1}. ${error}`]); + errorRow.getCell(1).fill = { + type: "pattern", + pattern: "solid", + fgColor: { argb: "FFFFCCCC" }, + }; + }); + + errorSheet.addRow([]); // Empty row for separation + } + + // Data validation errors section + const startRow = errorSheet.rowCount + 1; + + // Summary row + errorSheet.addRow([`DATA VALIDATION ERRORS: ${errors.length} errors found`]); + const summaryRow = errorSheet.getRow(startRow); + summaryRow.font = { bold: true, size: 12 }; + if (errors.length > 0) { + summaryRow.getCell(1).fill = { + type: "pattern", + pattern: "solid", + fgColor: { argb: "FFFFC0C0" }, + }; + } + + if (errors.length > 0) { + // Error data headers + const errorHeaders = [ + "TAG NO", + "Row Number", + "Column", + "Error Type", + "Error Message", + "Current Value", + "Expected Format", + ]; + + errorSheet.addRow(errorHeaders); + const headerRow = errorSheet.getRow(errorSheet.rowCount); + headerRow.font = { bold: true, color: { argb: "FFFFFFFF" } }; + headerRow.alignment = { horizontal: "center" }; + + headerRow.eachCell((cell) => { + cell.fill = { + type: "pattern", + pattern: "solid", + fgColor: { argb: "FFDC143C" }, + }; + }); + + // Add error data + errors.forEach((error) => { + const errorRow = errorSheet.addRow([ + error.tagNo, + error.rowIndex, + error.columnLabel, + error.errorType, + error.errorMessage, + error.currentValue || "", + error.expectedFormat || "", + ]); + + // Color code by error type + errorRow.eachCell((cell) => { + let bgColor = "FFFFFFFF"; // Default white + + switch (error.errorType) { + case "MISSING_TAG_NO": + bgColor = "FFFFCCCC"; // Light red + break; + case "TAG_NOT_FOUND": + bgColor = "FFFFDDDD"; // Very light red + break; + case "TYPE_MISMATCH": + bgColor = "FFFFEECC"; // Light orange + break; + case "INVALID_OPTION": + bgColor = "FFFFFFE0"; // Light yellow + break; + case "HEADER_MISMATCH": + bgColor = "FFFFE0E0"; // Very light red + break; + } + + cell.fill = { + type: "pattern", + pattern: "solid", + fgColor: { argb: bgColor }, + }; + }); + }); + } + + // Auto-fit columns + errorSheet.columns.forEach((column) => { + let maxLength = 0; + column.eachCell({ includeEmpty: false }, (cell) => { + const columnLength = String(cell.value).length; + if (columnLength > maxLength) { + maxLength = columnLength; + } + }); + column.width = Math.min(Math.max(maxLength + 2, 10), 50); + }); + + return errorSheet; +} + export async function importExcelData({ file, tableData, @@ -80,13 +221,13 @@ export async function importExcelData({ } // Validate headers - let headerErrorMessage = ""; + const headerErrors: string[] = []; // Check for missing required columns columnsJSON.forEach((col) => { const label = col.label; if (!headerToIndexMap.has(label)) { - headerErrorMessage += `Column "${label}" is missing. `; + headerErrors.push(`Column "${label}" is missing from Excel file`); } }); @@ -94,23 +235,24 @@ export async function importExcelData({ headerToIndexMap.forEach((index, headerLabel) => { const found = columnsJSON.some((col) => col.label === headerLabel); if (!found) { - headerErrorMessage += `Unexpected column "${headerLabel}" found in Excel. `; + headerErrors.push(`Unexpected column "${headerLabel}" found in Excel file`); } }); - // Add error column - const lastColIndex = worksheet.columnCount + 1; - worksheet.getRow(1).getCell(lastColIndex).value = "Error"; - - // If header validation fails, download error report and exit - if (headerErrorMessage) { - headerRow.getCell(lastColIndex).value = headerErrorMessage.trim(); + // If header validation fails, create error report and exit + if (headerErrors.length > 0) { + createImportErrorSheet(workbook, [], headerErrors); const outBuffer = await workbook.xlsx.writeBuffer(); - saveAs(new Blob([outBuffer]), `import-check-result_${Date.now()}.xlsx`); + saveAs(new Blob([outBuffer]), `import-error-report_${Date.now()}.xlsx`); - toast.error(`Header mismatch found. Please check downloaded file.`); - return { success: false, error: "Header mismatch" }; + toast.error(`Header validation failed. ${headerErrors.length} errors found. Check downloaded error report.`); + return { + success: false, + error: "Header validation errors", + errorCount: headerErrors.length, + hasErrors: true + }; } // Create column key to Excel index mapping @@ -124,8 +266,8 @@ export async function importExcelData({ // Parse and validate data rows const importedData: GenericData[] = []; + const validationErrors: ImportError[] = []; const lastRowNumber = worksheet.lastRow?.number || 1; - let errorCount = 0; const skippedFieldsLog: { tagNo: string, fields: string[] }[] = []; // 건너뛴 필드 로그 // Process each data row @@ -134,16 +276,40 @@ export async function importExcelData({ const rowValues = row.values as ExcelJS.CellValue[]; if (!rowValues || rowValues.length <= 1) continue; // Skip empty rows - let errorMessage = ""; - let warningMessage = ""; const rowObj: Record<string, any> = {}; const skippedFields: string[] = []; // 현재 행에서 건너뛴 필드들 + let hasErrors = false; // 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); + // Validate TAG_NO first + if (!tagNo) { + validationErrors.push({ + tagNo: `Row-${rowNum}`, + rowIndex: rowNum, + columnKey: "TAG_NO", + columnLabel: "TAG NO", + errorType: "MISSING_TAG_NO", + errorMessage: "TAG_NO is empty or missing", + currentValue: tagNo, + }); + hasErrors = true; + } else if (!existingTagNumbers.has(tagNo)) { + validationErrors.push({ + tagNo: tagNo, + rowIndex: rowNum, + columnKey: "TAG_NO", + columnLabel: "TAG NO", + errorType: "TAG_NOT_FOUND", + errorMessage: "TAG_NO not found in current data", + currentValue: tagNo, + }); + hasErrors = true; + } + // Process each column columnsJSON.forEach((col) => { const colIndex = keyToIndexMap.get(col.key); @@ -179,9 +345,6 @@ export async function importExcelData({ // Type-specific validation switch (col.type) { case "STRING": - if (!stringVal && col.key === "TAG_NO") { - errorMessage += `[${col.label}] is empty. `; - } rowObj[col.key] = stringVal; break; @@ -189,7 +352,17 @@ export async function importExcelData({ if (stringVal) { const num = parseFloat(stringVal); if (isNaN(num)) { - errorMessage += `[${col.label}] '${stringVal}' is not a valid number. `; + validationErrors.push({ + tagNo: tagNo || `Row-${rowNum}`, + rowIndex: rowNum, + columnKey: col.key, + columnLabel: col.label, + errorType: "TYPE_MISMATCH", + errorMessage: "Value is not a valid number", + currentValue: stringVal, + expectedFormat: "Number", + }); + hasErrors = true; } else { rowObj[col.key] = num; } @@ -199,14 +372,18 @@ export async function importExcelData({ break; case "LIST": - if ( - stringVal && - col.options && - !col.options.includes(stringVal) - ) { - errorMessage += `[${ - col.label - }] '${stringVal}' not in ${col.options.join(", ")}. `; + if (stringVal && col.options && !col.options.includes(stringVal)) { + validationErrors.push({ + tagNo: tagNo || `Row-${rowNum}`, + rowIndex: rowNum, + columnKey: col.key, + columnLabel: col.label, + errorType: "INVALID_OPTION", + errorMessage: "Value is not in the allowed options list", + currentValue: stringVal, + expectedFormat: col.options.join(", "), + }); + hasErrors = true; } rowObj[col.key] = stringVal; break; @@ -223,26 +400,10 @@ export async function importExcelData({ tagNo: tagNo, fields: skippedFields }); - warningMessage += `Skipped ${skippedFields.length} SHI-only fields. `; } - // Validate TAG_NO - const tagNum = rowObj["TAG_NO"]; - if (!tagNum) { - errorMessage += `No TAG_NO found. `; - } else if (!existingTagNumbers.has(tagNum)) { - errorMessage += `TagNumber '${tagNum}' is not in current data. `; - } - - // Record errors or add to valid data - if (errorMessage) { - 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()}`; - } + // Add to valid data only if no errors + if (!hasErrors) { importedData.push(rowObj); } } @@ -256,16 +417,22 @@ export async function importExcelData({ ); } - // If there are validation errors, download error report and exit - if (errorCount > 0) { + // If there are validation errors, create error report and exit + if (validationErrors.length > 0) { + createImportErrorSheet(workbook, validationErrors); + const outBuffer = await workbook.xlsx.writeBuffer(); - saveAs(new Blob([outBuffer]), `import-check-result_${Date.now()}.xlsx`); + saveAs(new Blob([outBuffer]), `import-error-report_${Date.now()}.xlsx`); + toast.error( - `There are ${errorCount} error row(s). Please check downloaded file.` + `Data validation failed. ${validationErrors.length} errors found across ${new Set(validationErrors.map(e => e.tagNo)).size} TAG(s). Check downloaded error report.` ); + return { success: false, error: "Data validation errors", + errorCount: validationErrors.length, + hasErrors: true, skippedFields: skippedFieldsLog }; } @@ -342,6 +509,8 @@ export async function importExcelData({ success: true, importedCount: successCount, message: `Partially successful: ${successCount} rows updated, ${errorCount} errors`, + errorCount: errorCount, + hasErrors: errorCount > 0, skippedFields: skippedFieldsLog }; } else { @@ -349,6 +518,8 @@ export async function importExcelData({ success: false, error: "All updates failed", message: errors.join("\n"), + errorCount: errorCount, + hasErrors: true, skippedFields: skippedFieldsLog }; } @@ -368,6 +539,8 @@ export async function importExcelData({ success: true, importedCount: successCount, message: "All data imported and saved to database", + errorCount: 0, + hasErrors: false, skippedFields: skippedFieldsLog }; } catch (saveError) { @@ -376,6 +549,8 @@ export async function importExcelData({ return { success: false, error: saveError, + errorCount: 1, + hasErrors: true, skippedFields: skippedFieldsLog }; } @@ -393,6 +568,8 @@ export async function importExcelData({ return { success: true, importedCount: importedData.length, + errorCount: 0, + hasErrors: false, skippedFields: skippedFieldsLog }; } @@ -400,7 +577,12 @@ export async function importExcelData({ } catch (err) { console.error("Excel import error:", err); toast.error("Excel import failed."); - return { success: false, error: err }; + return { + success: false, + error: err, + errorCount: 1, + hasErrors: true + }; } finally { if (onPendingChange) onPendingChange(false); } diff --git a/components/form-data/sedp-compare-dialog.tsx b/components/form-data/sedp-compare-dialog.tsx index 3107193a..647f2810 100644 --- a/components/form-data/sedp-compare-dialog.tsx +++ b/components/form-data/sedp-compare-dialog.tsx @@ -291,7 +291,7 @@ export function SEDPCompareDialog({ // Compare attributes const attributeComparisons = columnsJSON - .filter(col => col.key !== "TAG_NO" && col.key !== "TAG_DESC") + .filter(col => col.key !== "TAG_NO" && col.key !== "TAG_DESC"&& col.key !== "status") .map(col => { const localValue = localItem[col.key]; const sedpValue = sedpItem.attributes.get(col.key); |
