summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--components/data-table/data-table.tsx5
-rw-r--r--components/data-table/expandable-data-table.tsx226
-rw-r--r--components/form-data/export-excel-form.tsx321
-rw-r--r--components/form-data/form-data-table-columns.tsx47
-rw-r--r--components/form-data/import-excel-form.tsx284
-rw-r--r--components/form-data/sedp-compare-dialog.tsx2
-rw-r--r--lib/data-table.ts6
-rw-r--r--lib/forms/services.ts7
-rw-r--r--lib/tags/service.ts18
-rw-r--r--lib/vendor-document-list/table/enhanced-documents-table.tsx48
-rw-r--r--lib/vendor-document-list/table/stage-revision-expanded-content.tsx171
-rw-r--r--pages/api/pdftron/createVendorDataReports.ts73
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