"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"; import { FormDataReportTempUploadDialog } from "./form-data-report-temp-upload-dialog"; import { FormDataReportDialog } from "./form-data-report-dialog"; import { FormDataReportBatchDialog } from "./form-data-report-batch-dialog"; import { Popover, PopoverContent, PopoverTrigger, } from "@/components/ui/popover"; interface GenericData { [key: string]: any; } export interface DynamicTableProps { dataJSON: GenericData[]; columnsJSON: DataTableColumnJSON[]; contractItemId: number; formCode: string; formId: number; } export default function DynamicTable({ dataJSON, columnsJSON, contractItemId, formCode, formId, }: DynamicTableProps) { console.log({ columnsJSON }); 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); const [tempUpDialog, setTempUpDialog] = React.useState(false); const [reportData, setReportData] = React.useState([]); const [batchDownDialog, setBatchDownDialog] = React.useState(false); // Reference to the table instance const tableRef = React.useRef(null); const columns = React.useMemo( () => getColumns({ columnsJSON, setRowAction, setReportData }), [columnsJSON, setRowAction, setReportData] ); 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< DataTableAdvancedFilterField[] >(() => { 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} /> {tempUpDialog && ( )} {reportData.length > 0 && ( )} {batchDownDialog && ( )} ); }