"use client" import * as React from "react" import { useParams } from "next/navigation" import { useTranslation } from "@/i18n/client" import { ClientDataTable } from "../client-data-table/data-table" import { getColumns, DataTableRowAction, DataTableColumnJSON, ColumnType, } from "./form-data-table-columns" import type { DataTableAdvancedFilterField } from "@/types/table" import { Button } from "../ui/button" import { Download, Loader, Save, Upload } from "lucide-react" import { toast } from "sonner" import { syncMissingTags, updateFormDataInDB } from "@/lib/forms/services" import { UpdateTagSheet } from "./update-form-sheet" import ExcelJS from "exceljs" import { saveAs } from "file-saver" interface GenericData { [key: string]: any } export interface DynamicTableProps { dataJSON: GenericData[] columnsJSON: DataTableColumnJSON[] contractItemId: number formCode: string } export default function DynamicTable({ dataJSON, columnsJSON, contractItemId, formCode, }: DynamicTableProps) { const params = useParams() const lng = (params?.lng as string) || "ko" const { t } = useTranslation(lng, "translation") const [rowAction, setRowAction] = React.useState | null>(null) const [tableData, setTableData] = React.useState(() => dataJSON) const [isPending, setIsPending] = React.useState(false) const [isSaving, setIsSaving] = React.useState(false) // Reference to the table instance const tableRef = React.useRef(null) const columns = React.useMemo( () => getColumns({ columnsJSON, setRowAction }), [columnsJSON, setRowAction] ) function mapColumnTypeToAdvancedFilterType( columnType: ColumnType ): DataTableAdvancedFilterField["type"] { switch (columnType) { case "STRING": return "text" case "NUMBER": return "number" case "LIST": // "select"로 하셔도 되고 "multi-select"로 하셔도 됩니다. return "select" // 그 외 다른 타입들도 적절히 추가 매핑 default: // 예: 못 매핑한 경우 기본적으로 "text" 적용 return "text" } } const advancedFilterFields = React.useMemo[]>( () => { return columnsJSON.map((col) => ({ id: col.key, label: col.label, type: mapColumnTypeToAdvancedFilterType(col.type), options: col.type === "LIST" ? col.options?.map((v) => ({ label: v, value: v })) : undefined, })) }, [columnsJSON] ) // 1) 태그 불러오기 (기존) async function handleSyncTags() { try { setIsPending(true) const result = await syncMissingTags(contractItemId, formCode) // Prepare the toast messages based on what changed const changes = [] if (result.createdCount > 0) changes.push(`${result.createdCount}건 태그 생성`) if (result.updatedCount > 0) changes.push(`${result.updatedCount}건 태그 업데이트`) if (result.deletedCount > 0) changes.push(`${result.deletedCount}건 태그 삭제`) if (changes.length > 0) { // If any changes were made, show success message and reload toast.success(`동기화 완료: ${changes.join(', ')}`) location.reload() } else { // If no changes were made, show an info message toast.info("변경사항이 없습니다. 모든 태그가 최신 상태입니다.") } } catch (err) { console.error(err) toast.error("태그 동기화 중 에러가 발생했습니다.") } finally { setIsPending(false) } } // 2) Excel Import (새로운 기능) async function handleImportExcel(e: React.ChangeEvent) { const file = e.target.files?.[0] if (!file) return try { setIsPending(true) // 기존 테이블 데이터의 tagNumber 목록 (이미 있는 태그) const existingTagNumbers = new Set(tableData.map((d) => d.tagNumber)) const workbook = new ExcelJS.Workbook() const arrayBuffer = await file.arrayBuffer() await workbook.xlsx.load(arrayBuffer) const worksheet = workbook.worksheets[0] // (A) 헤더 파싱 (Error 열 추가 전에 먼저 체크) const headerRow = worksheet.getRow(1) const headerRowValues = headerRow.values as ExcelJS.CellValue[] // 디버깅용 로그 console.log("원본 헤더 값:", headerRowValues) // Excel의 헤더와 columnsJSON의 label 매핑 생성 // Excel의 행은 1부터 시작하므로 headerRowValues[0]은 undefined const headerToIndexMap = new Map() for (let i = 1; i < headerRowValues.length; i++) { const headerValue = String(headerRowValues[i] || "").trim() if (headerValue) { headerToIndexMap.set(headerValue, i) } } // (B) 헤더 검사 let headerErrorMessage = "" // (1) "columnsJSON에 있는데 엑셀에 없는" 라벨 columnsJSON.forEach((col) => { const label = col.label if (!headerToIndexMap.has(label)) { headerErrorMessage += `Column "${label}" is missing. ` } }) // (2) "엑셀에는 있는데 columnsJSON에 없는" 라벨 검사 headerToIndexMap.forEach((index, headerLabel) => { const found = columnsJSON.some((col) => col.label === headerLabel) if (!found) { headerErrorMessage += `Unexpected column "${headerLabel}" found in Excel. ` } }) // (C) 이제 Error 열 추가 const lastColIndex = worksheet.columnCount + 1 worksheet.getRow(1).getCell(lastColIndex).value = "Error" // 헤더 에러가 있으면 기록 후 다운로드하고 중단 if (headerErrorMessage) { headerRow.getCell(lastColIndex).value = headerErrorMessage.trim() const outBuffer = await workbook.xlsx.writeBuffer() saveAs(new Blob([outBuffer]), `import-check-result_${Date.now()}.xlsx`) toast.error(`Header mismatch found. Please check downloaded file.`) return } // -- 여기까지 왔다면, 헤더는 문제 없음 -- // 컬럼 키-인덱스 매핑 생성 (실제 데이터 행 파싱용) // columnsJSON의 key와 Excel 열 인덱스 간의 매핑 const keyToIndexMap = new Map() columnsJSON.forEach((col) => { const index = headerToIndexMap.get(col.label) if (index !== undefined) { keyToIndexMap.set(col.key, index) } }) // 데이터 파싱 const importedData: GenericData[] = [] const lastRowNumber = worksheet.lastRow?.number || 1 let errorCount = 0 // 실제 데이터 행 파싱 for (let rowNum = 2; rowNum <= lastRowNumber; rowNum++) { const row = worksheet.getRow(rowNum) const rowValues = row.values as ExcelJS.CellValue[] if (!rowValues || rowValues.length <= 1) continue // 빈 행 스킵 let errorMessage = "" const rowObj: Record = {} // 각 열에 대해 처리 columnsJSON.forEach((col) => { const colIndex = keyToIndexMap.get(col.key) if (colIndex === undefined) return const cellValue = rowValues[colIndex] ?? "" let stringVal = String(cellValue).trim() // 타입별 검사 switch (col.type) { case "STRING": if (!stringVal && col.key === "tagNumber") { errorMessage += `[${col.label}] is empty. ` } rowObj[col.key] = stringVal break case "NUMBER": if (stringVal) { const num = parseFloat(stringVal) if (isNaN(num)) { errorMessage += `[${col.label}] '${stringVal}' is not a valid number. ` } else { rowObj[col.key] = num } } else { rowObj[col.key] = null } break case "LIST": if (stringVal && col.options && !col.options.includes(stringVal)) { errorMessage += `[${col.label}] '${stringVal}' not in ${col.options.join(", ")}. ` } rowObj[col.key] = stringVal break default: rowObj[col.key] = stringVal break } }) // tagNumber 검사 const tagNum = rowObj["tagNumber"] if (!tagNum) { errorMessage += `No tagNumber found. ` } else if (!existingTagNumbers.has(tagNum)) { errorMessage += `TagNumber '${tagNum}' is not in current data. ` } if (errorMessage) { row.getCell(lastColIndex).value = errorMessage.trim() errorCount++ } else { importedData.push(rowObj) } } // 에러가 있으면 재다운로드 후 import 중단 if (errorCount > 0) { const outBuffer = await workbook.xlsx.writeBuffer() saveAs(new Blob([outBuffer]), `import-check-result_${Date.now()}.xlsx`) toast.error(`There are ${errorCount} error row(s). Please check downloaded file.`) return } // 에러 없으니 tableData 병합 setTableData((prev) => { const newDataMap = new Map() // 기존 데이터를 맵에 추가 prev.forEach((item) => { if (item.tagNumber) { newDataMap.set(item.tagNumber, { ...item }) } }) // 임포트 데이터로 기존 데이터 업데이트 importedData.forEach((item) => { const tag = item.tagNumber if (!tag) return const oldItem = newDataMap.get(tag) || {} newDataMap.set(tag, { ...oldItem, ...item }) }) return Array.from(newDataMap.values()) }) toast.success(`Imported ${importedData.length} rows successfully.`) } catch (err) { console.error("Excel import error:", err) toast.error("Excel import failed.") } finally { setIsPending(false) e.target.value = "" } } // 3) Save -> 서버에 전체 tableData를 저장 async function handleSave() { try { setIsSaving(true) // 유효성 검사 const invalidData = tableData.filter(item => !item.tagNumber?.trim()) if (invalidData.length > 0) { toast.error(`태그 번호가 없는 항목이 ${invalidData.length}개 있습니다.`) return } // 서버 액션 호출 const result = await updateFormDataInDB(formCode, contractItemId, tableData) if (result.success) { toast.success(result.message) } else { toast.error(result.message) } } catch (err) { console.error("Save error:", err) toast.error("데이터 저장 중 오류가 발생했습니다.") } finally { setIsSaving(false) } } // 4) NEW: Excel Export with data validation for select columns using a hidden validation sheet async function handleExportExcel() { try { setIsPending(true) // Create a new workbook const workbook = new ExcelJS.Workbook() // 데이터 시트 생성 const worksheet = workbook.addWorksheet("Data") // 유효성 검사용 숨김 시트 생성 const validationSheet = workbook.addWorksheet("ValidationData") validationSheet.state = 'hidden' // 시트 숨김 처리 // 1. 유효성 검사 시트에 select 옵션 추가 const selectColumns = columnsJSON.filter(col => col.type === "LIST" && col.options && col.options.length > 0 ) // 유효성 검사 범위 저장 맵 (컬럼 키 -> 유효성 검사 범위) const validationRanges = new Map() selectColumns.forEach((col, idx) => { const colIndex = idx + 1 const colLetter = validationSheet.getColumn(colIndex).letter // 헤더 추가 (컬럼 레이블) validationSheet.getCell(`${colLetter}1`).value = col.label // 옵션 추가 if (col.options) { col.options.forEach((option, optIdx) => { validationSheet.getCell(`${colLetter}${optIdx + 2}`).value = option }) // 유효성 검사 범위 저장 (ValidationData!$A$2:$A$4 형식) validationRanges.set( col.key, `ValidationData!${colLetter}$2:${colLetter}${col.options.length + 1}` ) } }) // 2. 데이터 시트에 헤더 추가 const headers = columnsJSON.map(col => col.label) worksheet.addRow(headers) // 헤더 스타일 적용 const headerRow = worksheet.getRow(1) headerRow.font = { bold: true } headerRow.alignment = { horizontal: 'center' } headerRow.eachCell((cell) => { cell.fill = { type: 'pattern', pattern: 'solid', fgColor: { argb: 'FFCCCCCC' } } }) // 3. 데이터 행 추가 tableData.forEach(row => { const rowValues = columnsJSON.map(col => { const value = row[col.key] return value !== undefined && value !== null ? value : '' }) worksheet.addRow(rowValues) }) // 4. 데이터 유효성 검사 적용 const maxRows = 5000 // 데이터 유효성 검사를 적용할 최대 행 수 columnsJSON.forEach((col, idx) => { if (col.type === "LIST" && validationRanges.has(col.key)) { const colLetter = worksheet.getColumn(idx + 1).letter const validationRange = validationRanges.get(col.key)! // 유효성 검사 정의 const validation = { type: 'list' as const, allowBlank: true, formulae: [validationRange], showErrorMessage: true, errorStyle: 'warning' as const, errorTitle: '유효하지 않은 값', error: '목록에서 값을 선택해주세요.' } // 모든 데이터 행에 유효성 검사 적용 (최대 maxRows까지) for (let rowIdx = 2; rowIdx <= Math.min(tableData.length + 1, maxRows); rowIdx++) { worksheet.getCell(`${colLetter}${rowIdx}`).dataValidation = validation } // 빈 행에도 적용 (최대 maxRows까지) if (tableData.length + 1 < maxRows) { for (let rowIdx = tableData.length + 2; rowIdx <= maxRows; rowIdx++) { worksheet.getCell(`${colLetter}${rowIdx}`).dataValidation = validation } } } }) // 5. 컬럼 너비 자동 조정 columnsJSON.forEach((col, idx) => { const column = worksheet.getColumn(idx + 1) // 최적 너비 계산 let maxLength = col.label.length tableData.forEach(row => { const value = row[col.key] if (value !== undefined && value !== null) { const valueLength = String(value).length if (valueLength > maxLength) { maxLength = valueLength } } }) // 너비 설정 (최소 10, 최대 50) column.width = Math.min(Math.max(maxLength + 2, 10), 50) }) // 6. 파일 다운로드 const buffer = await workbook.xlsx.writeBuffer() saveAs(new Blob([buffer]), `${formCode}_data_${new Date().toISOString().slice(0, 10)}.xlsx`) toast.success("Excel 내보내기 완료!") } catch (err) { console.error("Excel export error:", err) toast.error("Excel 내보내기 실패.") } finally { setIsPending(false) } } return ( <> {/* 버튼 그룹 */}
{/* 태그 불러오기 버튼 */} {/* IMPORT 버튼 (파일 선택) */} {/* EXPORT 버튼 (새로 추가) */} {/* SAVE 버튼 */}
{ if (!open) setRowAction(null) }} columns={columnsJSON} rowData={rowAction?.row.original ?? null} formCode={formCode} contractItemId={contractItemId} /> ) }