From 1a2241c40e10193c5ff7008a7b7b36cc1d855d96 Mon Sep 17 00:00:00 2001 From: joonhoekim <26rote@gmail.com> Date: Tue, 25 Mar 2025 15:55:45 +0900 Subject: initial commit --- components/form-data/form-data-table.tsx | 545 +++++++++++++++++++++++++++++++ 1 file changed, 545 insertions(+) create mode 100644 components/form-data/form-data-table.tsx (limited to 'components/form-data/form-data-table.tsx') diff --git a/components/form-data/form-data-table.tsx b/components/form-data/form-data-table.tsx new file mode 100644 index 00000000..14fff12e --- /dev/null +++ b/components/form-data/form-data-table.tsx @@ -0,0 +1,545 @@ +"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} + /> + + ) +} \ No newline at end of file -- cgit v1.2.3