diff options
| -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 | ||||
| -rw-r--r-- | lib/data-table.ts | 6 | ||||
| -rw-r--r-- | lib/forms/services.ts | 7 | ||||
| -rw-r--r-- | lib/tags/service.ts | 18 | ||||
| -rw-r--r-- | lib/vendor-document-list/table/enhanced-documents-table.tsx | 48 | ||||
| -rw-r--r-- | lib/vendor-document-list/table/stage-revision-expanded-content.tsx | 171 | ||||
| -rw-r--r-- | pages/api/pdftron/createVendorDataReports.ts | 73 |
12 files changed, 932 insertions, 276 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); diff --git a/lib/data-table.ts b/lib/data-table.ts index 4ad57d76..fd9309ab 100644 --- a/lib/data-table.ts +++ b/lib/data-table.ts @@ -20,9 +20,11 @@ import { FilterFn, Row } from "@tanstack/react-table" export function getCommonPinningStylesWithBorder<TData>({ column, withBorder = true, + isHeader = false, }: { column: Column<TData> withBorder?: boolean + isHeader?: boolean }): React.CSSProperties { const pinnedSide = column.getIsPinned() as "left" | "right" | false @@ -38,8 +40,8 @@ export function getCommonPinningStylesWithBorder<TData>({ /* ▒▒ 기타 스타일 ▒▒ */ width: column.getSize(), - // 불투명한 배경색 설정 - 테이블의 배경색과 동일하게 - background: pinnedSide ? "hsl(var(--background))" : "transparent", + // 헤더는 항상 불투명, 셀은 핀된 경우에만 불투명 + background: isHeader || pinnedSide ? "hsl(var(--background))" : "transparent", zIndex: pinnedSide ? 1 : 0, } diff --git a/lib/forms/services.ts b/lib/forms/services.ts index b6e479a2..27f2f5c2 100644 --- a/lib/forms/services.ts +++ b/lib/forms/services.ts @@ -307,6 +307,13 @@ export async function getFormData(formCode: string, contractItemId: number) { if (entry) { if (Array.isArray(entry.data)) { data = entry.data; + + data.sort((a, b) => { + const statusA = a.status || ''; + const statusB = b.status || ''; + return statusB.localeCompare(statusA); + }); + } else { console.warn("formEntries data was not an array. Using empty array."); } diff --git a/lib/tags/service.ts b/lib/tags/service.ts index 187aba39..e65ab65b 100644 --- a/lib/tags/service.ts +++ b/lib/tags/service.ts @@ -24,8 +24,8 @@ interface CreatedOrExistingForm { export async function getTags(input: GetTagsSchema, packagesId: number) { - return unstable_cache( - async () => { + // return unstable_cache( + // async () => { try { const offset = (input.page - 1) * input.perPage; @@ -79,13 +79,13 @@ export async function getTags(input: GetTagsSchema, packagesId: number) { // 에러 발생 시 디폴트 return { data: [], pageCount: 0 }; } - }, - [JSON.stringify(input), String(packagesId)], // 캐싱 키에 packagesId 추가 - { - revalidate: 3600, - tags: [`tags-${packagesId}`], // 패키지별 태그 사용 - } - )(); + // }, + // [JSON.stringify(input), String(packagesId)], // 캐싱 키에 packagesId 추가 + // { + // revalidate: 3600, + // tags: [`tags-${packagesId}`], // 패키지별 태그 사용 + // } + // )(); } export async function createTag( diff --git a/lib/vendor-document-list/table/enhanced-documents-table.tsx b/lib/vendor-document-list/table/enhanced-documents-table.tsx index 14c52455..f840a10c 100644 --- a/lib/vendor-document-list/table/enhanced-documents-table.tsx +++ b/lib/vendor-document-list/table/enhanced-documents-table.tsx @@ -468,26 +468,34 @@ export function EnhancedDocumentsTable({ {/* 메인 테이블 - 가로스크롤 문제 해결을 위한 구조 개선 */} <div className="space-y-4"> <div className="rounded-md border bg-white overflow-hidden"> - <ExpandableDataTable - table={table} - expandable={true} - expandedRows={expandedRows} - setExpandedRows={setExpandedRows} - renderExpandedContent={(document) => ( - // ✅ 확장된 내용을 별도 컨테이너로 분리하여 가로스크롤 영향 차단 - <div className=""> - <StageRevisionExpandedContent - document={document} - onUploadRevision={handleUploadRevision} - projectType={projectType} - expandedStages={expandedStages[String(document.documentId)] || {}} - onStageToggle={(stageId) => handleStageToggle(String(document.documentId), stageId)} - /> - </div> - )} - // 확장된 행에 대한 특별한 스타일링 - expandedRowClassName="!p-0" - > + <ExpandableDataTable + table={table} + expandable={true} + expandedRows={expandedRows} + setExpandedRows={setExpandedRows} + renderExpandedContent={(document) => ( + <div className=""> + <StageRevisionExpandedContent + document={document} + onUploadRevision={handleUploadRevision} + projectType={projectType} + expandedStages={expandedStages[String(document.documentId)] || {}} + onStageToggle={(stageId) => handleStageToggle(String(document.documentId), stageId)} + /> + </div> + )} + expandedRowClassName="!p-0" + // clickableColumns={[ + // 'docNumber', + // 'title', + // 'currentStageStatus', + // 'progressPercentage', + // ]} + excludeFromClick={[ + 'actions', + 'select' + ]} + > <DataTableAdvancedToolbar table={table} filterFields={advancedFilterFields} diff --git a/lib/vendor-document-list/table/stage-revision-expanded-content.tsx b/lib/vendor-document-list/table/stage-revision-expanded-content.tsx index d9d53cc9..a4de03b7 100644 --- a/lib/vendor-document-list/table/stage-revision-expanded-content.tsx +++ b/lib/vendor-document-list/table/stage-revision-expanded-content.tsx @@ -13,7 +13,7 @@ import { DialogHeader, DialogTitle, } from "@/components/ui/dialog" -import { +import { Table, TableBody, TableCell, @@ -21,7 +21,7 @@ import { TableHeader, TableRow, } from "@/components/ui/table" -import { +import { FileText, User, Calendar, @@ -86,7 +86,7 @@ const getPriorityText = (priority: string) => { const getFileIconColor = (fileName: string) => { const ext = fileName.split('.').pop()?.toLowerCase() - switch(ext) { + switch (ext) { case 'pdf': return 'text-red-500' case 'doc': case 'docx': return 'text-blue-500' case 'xls': case 'xlsx': return 'text-green-500' @@ -105,7 +105,7 @@ interface StageRevisionExpandedContentProps { onStageToggle?: (stageId: number) => void } -export const StageRevisionExpandedContent = ({ +export const StageRevisionExpandedContent = ({ document: documentData, onUploadRevision, onStageStatusUpdate, @@ -117,7 +117,7 @@ export const StageRevisionExpandedContent = ({ // 로컬 상태 관리 const [localExpandedStages, setLocalExpandedStages] = React.useState<Record<number, boolean>>({}) const [expandedRevisions, setExpandedRevisions] = React.useState<Set<number>>(new Set()) - + // ✅ 문서 뷰어 상태 관리 const [viewerOpen, setViewerOpen] = React.useState(false) const [selectedRevisions, setSelectedRevisions] = React.useState<any[]>([]) @@ -127,11 +127,11 @@ export const StageRevisionExpandedContent = ({ const viewer = React.useRef<HTMLDivElement>(null) const initialized = React.useRef(false) const isCancelled = React.useRef(false) - + // 상위에서 관리하는지 로컬에서 관리하는지 결정 const isExternallyManaged = onStageToggle !== undefined const currentExpandedStages = isExternallyManaged ? expandedStages : localExpandedStages - + const handleStageToggle = React.useCallback((stageId: number) => { if (isExternallyManaged && onStageToggle) { onStageToggle(stageId) @@ -157,7 +157,7 @@ export const StageRevisionExpandedContent = ({ // ✅ PDF 뷰어 정리 함수 const cleanupHtmlStyle = React.useCallback(() => { - const htmlElement = window.document.documentElement + const htmlElement = window.document.documentElement const originalStyle = htmlElement.getAttribute("style") || "" const colorSchemeStyle = originalStyle .split(";") @@ -185,12 +185,12 @@ export const StageRevisionExpandedContent = ({ console.log(attachment) try { // ID를 우선으로 사용, 없으면 filePath 사용 - const queryParam = attachment.id + const queryParam = attachment.id ? `id=${encodeURIComponent(attachment.id)}` : `path=${encodeURIComponent(attachment.filePath)}` - + const response = await fetch(`/api/document-download?${queryParam}`) - + if (!response.ok) { const errorData = await response.json() throw new Error(errorData.error || '파일 다운로드에 실패했습니다.') @@ -205,7 +205,7 @@ export const StageRevisionExpandedContent = ({ link.click() window.document.body.removeChild(link) window.URL.revokeObjectURL(url) - + console.log('✅ 파일 다운로드 완료:', attachment.fileName) } catch (error) { console.error('❌ 파일 다운로드 오류:', error) @@ -312,7 +312,7 @@ export const StageRevisionExpandedContent = ({ const handleCloseViewer = React.useCallback(async () => { if (!fileSetLoading) { isCancelled.current = true - + if (instance) { try { await instance.UI.dispose() @@ -342,7 +342,7 @@ export const StageRevisionExpandedContent = ({ </div> ) } - + return ( <> <div className="w-full max-w-none bg-gray-50" onClick={(e) => e.stopPropagation()}> @@ -366,27 +366,28 @@ export const StageRevisionExpandedContent = ({ 새 리비전 업로드 </Button> */} </div> - + <ScrollArea className="h-[400px] w-full"> <div className="space-y-3 pr-4"> {stagesWithRevisions.map((stage) => { const isExpanded = currentExpandedStages[stage.id] || false const revisions = stage.revisions || [] - + return ( <div key={stage.id} className="bg-white rounded border shadow-sm overflow-hidden"> - {/* 스테이지 헤더 */} - <div className="py-2 px-3 bg-gray-50 border-b"> + {/* 스테이지 헤더 - 전체 영역 클릭 가능 */} + <div + className="py-2 px-3 bg-gray-50 border-b cursor-pointer hover:bg-gray-100 transition-colors" + onClick={(e) => { + e.preventDefault() + e.stopPropagation() + handleStageToggle(stage.id) + }} + > <div className="flex items-center justify-between"> <div className="flex items-center gap-3"> - <button - className="flex items-center gap-2 hover:bg-gray-100 p-1 rounded transition-colors" - onClick={(e) => { - e.preventDefault() - e.stopPropagation() - handleStageToggle(stage.id) - }} - > + {/* 버튼 영역 - 이제 시각적 표시만 담당 */} + <div className="flex items-center gap-2"> <div className="flex items-center gap-2"> <div className="w-6 h-6 rounded-full bg-white border-2 border-gray-300 flex items-center justify-center text-xs font-medium"> {stage.stageOrder || 1} @@ -394,17 +395,17 @@ export const StageRevisionExpandedContent = ({ <div className={cn( "w-2 h-2 rounded-full", stage.stageStatus === 'COMPLETED' ? 'bg-green-500' : - stage.stageStatus === 'IN_PROGRESS' ? 'bg-blue-500' : - stage.stageStatus === 'SUBMITTED' ? 'bg-purple-500' : - 'bg-gray-300' + stage.stageStatus === 'IN_PROGRESS' ? 'bg-blue-500' : + stage.stageStatus === 'SUBMITTED' ? 'bg-purple-500' : + 'bg-gray-300' )} /> - {isExpanded ? - <ChevronDown className="w-3 h-3 text-gray-500" /> : + {isExpanded ? + <ChevronDown className="w-3 h-3 text-gray-500" /> : <ChevronRight className="w-3 h-3 text-gray-500" /> } </div> - </button> - + </div> + <div className="flex-1"> <div className="flex items-center gap-2"> <div className="font-medium text-sm">{stage.stageName}</div> @@ -417,7 +418,7 @@ export const StageRevisionExpandedContent = ({ </div> </div> </div> - + <div className="flex items-center gap-4"> <div className="grid grid-cols-2 gap-2 text-xs"> <div> @@ -437,45 +438,51 @@ export const StageRevisionExpandedContent = ({ </div> )} </div> - - {/* 스테이지 액션 메뉴 */} - <DropdownMenu> - <DropdownMenuTrigger asChild> - <Button - variant="ghost" - size="sm" - className="h-7 w-7 p-0" - > - <MoreHorizontal className="h-3 w-3" /> - </Button> - </DropdownMenuTrigger> - <DropdownMenuContent align="end"> - {onStageStatusUpdate && ( - <> - <DropdownMenuItem onClick={() => onStageStatusUpdate(stage.id, 'IN_PROGRESS')}> - 진행 시작 - </DropdownMenuItem> - <DropdownMenuItem onClick={() => onStageStatusUpdate(stage.id, 'COMPLETED')}> - 완료 처리 - </DropdownMenuItem> - </> - )} - <DropdownMenuItem onClick={() => onUploadRevision(documentData, stage.stageName)}> - 리비전 업로드 - </DropdownMenuItem> - {/* ✅ 스테이지에 첨부파일이 있는 리비전이 있을 때만 문서 보기 버튼 표시 */} - {revisions.some(rev => rev.attachments && rev.attachments.length > 0) && ( - <DropdownMenuItem onClick={() => handleViewRevision(revisions.filter(rev => rev.attachments && rev.attachments.length > 0))}> - <Eye className="w-3 h-3 mr-1" /> - 스테이지 문서 보기 + + {/* 스테이지 액션 메뉴 - 클릭 이벤트 전파 차단 */} + <div + onClick={(e) => { + e.stopPropagation() // 액션 메뉴 클릭 시 스테이지 토글 방지 + }} + > + <DropdownMenu> + <DropdownMenuTrigger asChild> + <Button + variant="ghost" + size="sm" + className="h-7 w-7 p-0" + > + <MoreHorizontal className="h-3 w-3" /> + </Button> + </DropdownMenuTrigger> + <DropdownMenuContent align="end"> + {onStageStatusUpdate && ( + <> + <DropdownMenuItem onClick={() => onStageStatusUpdate(stage.id, 'IN_PROGRESS')}> + 진행 시작 + </DropdownMenuItem> + <DropdownMenuItem onClick={() => onStageStatusUpdate(stage.id, 'COMPLETED')}> + 완료 처리 + </DropdownMenuItem> + </> + )} + <DropdownMenuItem onClick={() => onUploadRevision(documentData, stage.stageName)}> + 리비전 업로드 </DropdownMenuItem> - )} - </DropdownMenuContent> - </DropdownMenu> + {/* ✅ 스테이지에 첨부파일이 있는 리비전이 있을 때만 문서 보기 버튼 표시 */} + {revisions.some(rev => rev.attachments && rev.attachments.length > 0) && ( + <DropdownMenuItem onClick={() => handleViewRevision(revisions.filter(rev => rev.attachments && rev.attachments.length > 0))}> + <Eye className="w-3 h-3 mr-1" /> + 스테이지 문서 보기 + </DropdownMenuItem> + )} + </DropdownMenuContent> + </DropdownMenu> + </div> </div> </div> </div> - + {/* 리비전 목록 - 테이블 형태 */} {isExpanded && ( <div className="max-h-72 overflow-y-auto"> @@ -499,29 +506,29 @@ export const StageRevisionExpandedContent = ({ <TableBody> {revisions.map((revision) => { const hasAttachments = revision.attachments && revision.attachments.length > 0 - + return ( <TableRow key={revision.id} className="hover:bg-gray-50 h-10"> {/* 리비전 */} <TableCell className="py-1 px-2"> <span className="text-xs font-semibold"> - {revision.uploaderType ==="vendor"?"To SHI":"From SHI"} + {revision.uploaderType === "vendor" ? "To SHI" : "From SHI"} </span> </TableCell> - + <TableCell className="py-1 px-2"> <span className="font-mono text-xs font-semibold bg-gray-100 px-1.5 py-0.5 rounded"> {revision.revision} </span> </TableCell> - + {/* 상태 */} <TableCell className="py-1 px-2"> <Badge className={cn("text-xs px-1.5 py-0.5", getStatusColor(revision.revisionStatus))}> {getStatusText(revision.revisionStatus)} </Badge> </TableCell> - + {/* 업로더 */} <TableCell className="py-1 px-2"> <div className="flex items-center gap-1"> @@ -529,20 +536,20 @@ export const StageRevisionExpandedContent = ({ <span className="text-xs truncate max-w-[60px]">{revision.uploaderName || '-'}</span> </div> </TableCell> - {/* 제출일 */} + {/* 제출일 */} <TableCell className="py-1 px-2"> <span className="text-xs text-gray-600"> {revision.uploadedAt ? formatDate(revision.uploadedAt) : '-'} </span> </TableCell> - + {/* 제출일 */} <TableCell className="py-1 px-2"> <span className="text-xs text-gray-600"> {revision.externalSentDate ? formatDate(revision.externalSentDate) : '-'} </span> </TableCell> - + {/* 승인/반려일 */} <TableCell className="py-1 px-2"> <div className="text-xs text-gray-600"> @@ -569,7 +576,7 @@ export const StageRevisionExpandedContent = ({ )} </div> </TableCell> - + {/* ✅ 첨부파일 - 클릭 시 다운로드, 별도 뷰어 버튼 */} <TableCell className="py-1 px-2"> {hasAttachments ? ( @@ -588,7 +595,7 @@ export const StageRevisionExpandedContent = ({ </Button> ))} {revision.attachments.length > 4 && ( - <span + <span className="text-xs text-gray-500 ml-0.5" title={`총 ${revision.attachments.length}개 파일`} > @@ -610,7 +617,7 @@ export const StageRevisionExpandedContent = ({ <span className="text-gray-400 text-xs">-</span> )} </TableCell> - + {/* 액션 */} <TableCell className="py-1 px-2"> <div className="flex gap-0.5"> @@ -647,7 +654,7 @@ export const StageRevisionExpandedContent = ({ </Button> </div> </TableCell> - + {/* 코멘트 */} <TableCell className="py-1 px-2"> {revision.comment ? ( @@ -703,7 +710,7 @@ export const StageRevisionExpandedContent = ({ <DialogHeader className="h-[38px]"> <DialogTitle>문서 미리보기</DialogTitle> <DialogDescription> - {selectedRevisions.length === 1 + {selectedRevisions.length === 1 ? `리비전 ${selectedRevisions[0]?.revision} 첨부파일` : `${selectedRevisions.length}개 리비전 첨부파일` } diff --git a/pages/api/pdftron/createVendorDataReports.ts b/pages/api/pdftron/createVendorDataReports.ts index f461a7fb..f0c42926 100644 --- a/pages/api/pdftron/createVendorDataReports.ts +++ b/pages/api/pdftron/createVendorDataReports.ts @@ -10,6 +10,64 @@ export const config = { }, }; +// 서버 사이드용 DRM 복호화 함수 (API 라우트 내부에 정의) +async function decryptBufferWithDRM(buffer: Buffer, originalFileName: string): Promise<Buffer> { + try { + // Buffer를 Blob으로 변환하여 FormData에 추가 + const blob = new Blob([buffer]); + const file = new File([blob], originalFileName); + + const formData = new FormData(); + formData.append('file', file); + + // 로컬 6543 포트에 drm-proxy 서버가 실행되고 있어야 함 + const backendUrl = "http://localhost:6543/api/drm-proxy/decrypt"; + + console.log(`[DRM] 서버에서 파일 복호화 시도: ${originalFileName} (크기: ${buffer.length} bytes)`); + + const response = await fetch(backendUrl, { + method: "POST", + body: formData, + }); + + if (!response.ok) { + const errorText = await response.text().catch(() => '응답 텍스트를 가져올 수 없음'); + throw new Error(`DRM 서버 응답 오류 [${response.status}]: ${errorText}`); + } + + // 응답을 ArrayBuffer로 받아서 Buffer로 변환 + const arrayBuffer = await response.arrayBuffer(); + const decryptedBuffer = Buffer.from(arrayBuffer); + + console.log(`[DRM] 서버에서 파일 복호화 성공: ${originalFileName} (결과 크기: ${decryptedBuffer.length} bytes)`); + + return decryptedBuffer; + + } catch (error) { + // 오류 발생시 로깅하며, 폴백으로 복호화되지 않은 원본 버퍼를 리턴 + const errorMessage = error instanceof Error + ? `${error.name}: ${error.message}` + : String(error); + + console.error(`[DRM] 서버 복호화 오류: ${errorMessage}`, { + fileName: originalFileName, + fileSize: buffer.length, + remark: ` + [정상 동작 안내] + DTS 개발 서버나 로컬 환경에서는 에러가 발생하는 것이 정상적인 동작입니다. + 이 경우 원본 파일 버퍼가 그대로 반환됩니다. + + [발생 가능한 에러 케이스] + 1. DRM 백엔드 서버가 없는 경우 - CONNECTION_REJECTED 발생 + 2. DRM 중앙 서버와 통신 불가한 경우 - PARENT CERT 속성 추가 불가로 인한 백엔드측 500 에러 + `, + error + }); + + return buffer; // 원본 버퍼 반환 (폴백) + } +} + export default async function handler( req: NextApiRequest, res: NextApiResponse @@ -44,13 +102,22 @@ export default async function handler( return res.status(400).json({ error: "Invalid Report Data" }); } - const buffer = await fs.readFile(reportCoverPage.filepath); + // 원본 파일 읽기 + const originalBuffer = await fs.readFile(reportCoverPage.filepath); + + // DRM 복호화 처리 + console.log(`[DRM] 파일 복호화 시작: ${reportCoverPage.originalFilename || 'unknown'}`); + const decryptedBuffer = await decryptBufferWithDRM( + originalBuffer, + reportCoverPage.originalFilename || 'document.docx' + ); + // 복호화된 버퍼로 리포트 생성 const { result, buffer: pdfBuffer, error, - } = await createReport(buffer, reportTempPath, reportDatas); + } = await createReport(decryptedBuffer, reportTempPath, reportDatas); if (result && pdfBuffer) { res.setHeader("Content-Type", "application/pdf"); @@ -75,4 +142,4 @@ export default async function handler( } catch (err) { return res.status(401).end(); } -} +}
\ No newline at end of file |
