summaryrefslogtreecommitdiff
path: root/components/form-data/form-data-table.tsx
diff options
context:
space:
mode:
Diffstat (limited to 'components/form-data/form-data-table.tsx')
-rw-r--r--components/form-data/form-data-table.tsx545
1 files changed, 545 insertions, 0 deletions
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<DataTableRowAction<GenericData> | null>(null)
+ const [tableData, setTableData] = React.useState<GenericData[]>(() => 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<GenericData>({ columnsJSON, setRowAction }),
+ [columnsJSON, setRowAction]
+ )
+
+ function mapColumnTypeToAdvancedFilterType(
+ columnType: ColumnType
+ ): DataTableAdvancedFilterField<GenericData>["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<GenericData>[]>(
+ () => {
+ 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<HTMLInputElement>) {
+ 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<string, number>()
+ 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<string, number>()
+ 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<string, any> = {}
+
+ // 각 열에 대해 처리
+ 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<string, GenericData>()
+
+ // 기존 데이터를 맵에 추가
+ 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<string, string>()
+
+ 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 (
+ <>
+ <ClientDataTable
+ data={tableData}
+ columns={columns}
+ advancedFilterFields={advancedFilterFields}
+ // tableRef={tableRef}
+ >
+ {/* 버튼 그룹 */}
+ <div className="flex items-center gap-2">
+ {/* 태그 불러오기 버튼 */}
+ <Button variant="default" size="sm" onClick={handleSyncTags} disabled={isPending}>
+ {isPending && <Loader className="mr-2 size-4 animate-spin" aria-hidden="true" />}
+ Sync Tags
+ </Button>
+
+ {/* IMPORT 버튼 (파일 선택) */}
+ <Button asChild variant="outline" size="sm" disabled={isPending}>
+ <label>
+ <Upload className="size-4" />
+ Import
+ <input
+ type="file"
+ accept=".xlsx,.xls"
+ onChange={handleImportExcel}
+ style={{ display: "none" }}
+ />
+ </label>
+ </Button>
+
+ {/* EXPORT 버튼 (새로 추가) */}
+ <Button variant="outline" size="sm" onClick={handleExportExcel} disabled={isPending}>
+ <Download className="mr-2 size-4" />
+ Export Template
+ </Button>
+
+ {/* SAVE 버튼 */}
+ <Button
+ variant="outline"
+ size="sm"
+ onClick={handleSave}
+ disabled={isPending || isSaving}
+ >
+ {isSaving ? (
+ <>
+ <Loader className="mr-2 size-4 animate-spin" />
+ 저장 중...
+ </>
+ ) : (
+ <>
+ <Save className="mr-2 size-4" />
+ Save
+ </>
+ )}
+ </Button>
+ </div>
+ </ClientDataTable>
+
+ <UpdateTagSheet
+ open={rowAction?.type === "update"}
+ onOpenChange={(open) => {
+ if (!open) setRowAction(null)
+ }}
+ columns={columnsJSON}
+ rowData={rowAction?.row.original ?? null}
+ formCode={formCode}
+ contractItemId={contractItemId}
+ />
+ </>
+ )
+} \ No newline at end of file