// components/vendor-data-plant/tags-table.tsx "use client" import * as React from "react" import type { DataTableAdvancedFilterField, DataTableFilterField, DataTableRowAction, } from "@/types/table" import { useRouter } from "next/navigation" import { toast } from "sonner" import { Trash2, Download, Upload, Loader2, RefreshCcw, Plus } from "lucide-react" import ExcelJS from "exceljs" import type { Table as TanstackTable } from "@tanstack/react-table" import { ClientDataTable } from "@/components/client-data-table/data-table" import { getColumns } from "./tag-table-column" import { Tag } from "@/db/schema/vendorData" import { DeleteTagsDialog } from "./delete-tags-dialog" import { UpdateTagSheet } from "./update-tag-sheet" import { AddTagDialog } from "./add-tag-dialog" import { useAtomValue } from 'jotai' import { selectedModeAtom } from '@/atoms' import { Skeleton } from "@/components/ui/skeleton" import type { ColumnDef } from "@tanstack/react-table" import { createDynamicAttributeColumns } from "../column-builder.service" import { getAllTagsPlant, getUniqueAttributeKeys } from "../queries" import { Button } from "@/components/ui/button" import { exportTagsToExcel } from "./tags-export" import { bulkCreateTags, getClassOptions, getProjectIdFromContractItemId, getSubfieldsByTagType } from "../service" import { decryptWithServerAction } from "@/components/drm/drmUtils" interface TagsTableProps { projectCode: string packageCode: string } // 태그 넘버링 룰 인터페이스 (Import용) interface TagNumberingRule { attributesId: string; attributesDescription: string; expression: string | null; delimiter: string | null; sortOrder: number; } interface ClassOption { code: string; label: string; tagTypeCode: string; tagTypeDescription: string; } interface SubFieldDef { name: string; label: string; type: "select" | "text"; options?: { value: string; label: string }[]; expression?: string; delimiter?: string; } export function TagsTable({ projectCode, packageCode, }: TagsTableProps) { const router = useRouter() const selectedMode = useAtomValue(selectedModeAtom) // 상태 관리 const [tableData, setTableData] = React.useState([]) const [columns, setColumns] = React.useState[]>([]) const [isLoading, setIsLoading] = React.useState(true) const [rowAction, setRowAction] = React.useState | null>(null) console.log(tableData,"tableData") // 선택된 행 관리 const [selectedRowsData, setSelectedRowsData] = React.useState([]) const [clearSelection, setClearSelection] = React.useState(false) // 다이얼로그 상태 const [deleteDialogOpen, setDeleteDialogOpen] = React.useState(false) const [deleteTarget, setDeleteTarget] = React.useState([]) const [addTagDialogOpen, setAddTagDialogOpen] = React.useState(false) // Import/Export 상태 const [isPending, setIsPending] = React.useState(false) const [isExporting, setIsExporting] = React.useState(false) const fileInputRef = React.useRef(null) // Sync 상태 const [isSyncing, setIsSyncing] = React.useState(false) const [syncId, setSyncId] = React.useState(null) const pollingRef = React.useRef(null) // Table ref for export const tableRef = React.useRef | null>(null) // Cache for validation const [classOptions, setClassOptions] = React.useState([]) const [subfieldCache, setSubfieldCache] = React.useState>({}) const [projectId, setProjectId] = React.useState(null) // Load project ID React.useEffect(() => { const fetchProjectId = async () => { if (packageCode && projectCode) { try { const pid = await getProjectIdFromContractItemId(projectCode) setProjectId(pid) } catch (error) { console.error("Failed to fetch project ID:", error) } } } fetchProjectId() }, [projectCode]) // Load class options React.useEffect(() => { const loadClassOptions = async () => { try { const options = await getClassOptions(packageCode, projectCode) setClassOptions(options) } catch (error) { console.error("Failed to load class options:", error) } } loadClassOptions() }, [packageCode, projectCode]) // 데이터 및 컬럼 로드 React.useEffect(() => { async function loadTableData() { try { setIsLoading(true) const [tagsData, attributeKeys] = await Promise.all([ getAllTagsPlant(projectCode, packageCode), getUniqueAttributeKeys(projectCode, packageCode), ]) const baseColumns = getColumns({ setRowAction, onDeleteClick: handleDeleteRow }) let dynamicColumns: ColumnDef[] = [] if (attributeKeys.length > 0) { dynamicColumns = createDynamicAttributeColumns(attributeKeys) } const actionsColumn = baseColumns.pop() const finalColumns = [ ...baseColumns, ...dynamicColumns, actionsColumn ].filter(Boolean) as ColumnDef[] setTableData(tagsData) setColumns(finalColumns) } catch (error) { console.error("Error loading table data:", error) toast.error("Failed to load table data") setTableData([]) setColumns(getColumns({ setRowAction, onDeleteClick: handleDeleteRow })) } finally { setIsLoading(false) } } loadTableData() }, [projectCode, packageCode]) // Filter fields const filterFields: DataTableFilterField[] = [ { id: "tagNo", label: "Tag Number", placeholder: "Filter Tag Number...", }, ] const advancedFilterFields: DataTableAdvancedFilterField[] = [ { id: "tagNo", label: "Tag No", type: "text", }, { id: "tagType", label: "Tag Type", type: "text", }, { id: "description", label: "Description", type: "text", }, { id: "class", label: "Class", type: "text", }, { id: "createdAt", label: "Created at", type: "date", }, { id: "updatedAt", label: "Updated at", type: "date", }, ] // 선택된 행 개수 const selectedRowCount = React.useMemo(() => { return selectedRowsData.length }, [selectedRowsData]) // 개별 행 삭제 const handleDeleteRow = React.useCallback((rowData: Tag) => { setDeleteTarget([rowData]) setDeleteDialogOpen(true) }, []) // 배치 삭제 const handleBatchDelete = React.useCallback(() => { if (selectedRowsData.length === 0) { toast.error("삭제할 항목을 선택해주세요.") return } setDeleteTarget(selectedRowsData) setDeleteDialogOpen(true) }, [selectedRowsData]) // 삭제 성공 후 처리 const handleDeleteSuccess = React.useCallback(() => { const tagNosToDelete = deleteTarget .map(item => item.tagNo) .filter(Boolean) setTableData(prev => prev.filter(item => !tagNosToDelete.includes(item.tagNo)) ) setSelectedRowsData([]) setClearSelection(prev => !prev) setDeleteTarget([]) toast.success("삭제되었습니다.") }, [deleteTarget]) // 클래스 라벨로 태그 타입 코드 찾기 const getTagTypeCodeByClassLabel = React.useCallback((classLabel: string): string | null => { const classOption = classOptions.find(opt => opt.label === classLabel) return classOption?.tagTypeCode || null }, [classOptions]) // 태그 타입에 따른 서브필드 가져오기 const fetchSubfieldsByTagType = React.useCallback(async (tagTypeCode: string): Promise => { if (subfieldCache[tagTypeCode]) { return subfieldCache[tagTypeCode] } try { const { subFields } = await getSubfieldsByTagType(tagTypeCode, projectCode, "", "") const formattedSubFields: SubFieldDef[] = subFields.map(field => ({ name: field.name, label: field.label, type: field.type, options: field.options || [], expression: field.expression ?? undefined, delimiter: field.delimiter ?? undefined, })) setSubfieldCache(prev => ({ ...prev, [tagTypeCode]: formattedSubFields })) return formattedSubFields } catch (error) { console.error(`Error fetching subfields for tagType ${tagTypeCode}:`, error) return [] } }, [subfieldCache, projectCode]) // Class 기반 태그 번호 형식 검증 const validateTagNumberByClass = React.useCallback(async ( tagNo: string, classLabel: string ): Promise => { if (!tagNo) return "Tag number is empty." if (!classLabel) return "Class is empty." try { const tagTypeCode = getTagTypeCodeByClassLabel(classLabel) if (!tagTypeCode) { return `No tag type found for class '${classLabel}'.` } const subfields = await fetchSubfieldsByTagType(tagTypeCode) if (!subfields || subfields.length === 0) { return `No subfields found for tag type code '${tagTypeCode}'.` } let remainingTagNo = tagNo for (const field of subfields) { const delimiter = field.delimiter || "" let nextDelimiterPos if (delimiter && remainingTagNo.includes(delimiter)) { nextDelimiterPos = remainingTagNo.indexOf(delimiter) } else { nextDelimiterPos = remainingTagNo.length } const part = remainingTagNo.substring(0, nextDelimiterPos) if (!part) { return `Empty part for field '${field.label}'.` } if (field.expression) { try { let cleanPattern = field.expression.replace(/^\^/, '').replace(/\$$/, '') const regex = new RegExp(`^${cleanPattern}$`) if (!regex.test(part)) { return `Part '${part}' for field '${field.label}' does not match the pattern '${field.expression}'.` } } catch (error) { console.error(`Invalid regex pattern: ${field.expression}`, error) return `Invalid pattern for field '${field.label}': ${field.expression}` } } if (field.type === "select" && field.options && field.options.length > 0) { const validValues = field.options.map(opt => opt.value) if (!validValues.includes(part)) { return `'${part}' is not a valid value for field '${field.label}'. Valid options: ${validValues.join(", ")}.` } } if (delimiter && nextDelimiterPos < remainingTagNo.length) { remainingTagNo = remainingTagNo.substring(nextDelimiterPos + delimiter.length) } else { remainingTagNo = "" break } } if (remainingTagNo) { return `Tag number has extra parts: '${remainingTagNo}'.` } return "" } catch (error) { console.error("Error validating tag number by class:", error) return "Error validating tag number format." } }, [getTagTypeCodeByClassLabel, fetchSubfieldsByTagType]) // Import 파일 선택 const handleImportClick = () => { fileInputRef.current?.click() } // Import 파일 처리 const handleFileChange = async (e: React.ChangeEvent) => { const file = e.target.files?.[0] if (!file) return e.target.value = "" setIsPending(true) try { const workbook = new ExcelJS.Workbook() const arrayBuffer = await decryptWithServerAction(file) await workbook.xlsx.load(arrayBuffer) const worksheet = workbook.worksheets[0] const lastColIndex = worksheet.columnCount + 1 worksheet.getRow(1).getCell(lastColIndex).value = "Error" const headerRowValues = worksheet.getRow(1).values as ExcelJS.CellValue[] // Excel header to accessor mapping const excelHeaderToAccessor: Record = {} for (const col of columns) { const meta = col.meta as { excelHeader?: string } | undefined if (meta?.excelHeader) { const accessor = col.id as string excelHeaderToAccessor[meta.excelHeader] = accessor } } const accessorIndexMap: Record = {} for (let i = 1; i < headerRowValues.length; i++) { const cellVal = String(headerRowValues[i] ?? "").trim() if (!cellVal) continue const accessor = excelHeaderToAccessor[cellVal] if (accessor) { accessorIndexMap[accessor] = i } } let errorCount = 0 const importedRows: Tag[] = [] const fileTagNos = new Set() const lastRow = worksheet.lastRow?.number || 1 for (let rowNum = 2; rowNum <= lastRow; rowNum++) { const row = worksheet.getRow(rowNum) const rowVals = row.values as ExcelJS.CellValue[] if (!rowVals || rowVals.length <= 1) continue let errorMsg = "" const tagNoIndex = accessorIndexMap["tagNo"] const classIndex = accessorIndexMap["class"] const tagNo = tagNoIndex ? String(rowVals[tagNoIndex] ?? "").trim() : "" const classVal = classIndex ? String(rowVals[classIndex] ?? "").trim() : "" if (!tagNo) { errorMsg += `Tag No is empty. ` } if (!classVal) { errorMsg += `Class is empty. ` } if (tagNo) { const dup = tableData.find(t => t.tagNo === tagNo) if (dup) { errorMsg += `TagNo '${tagNo}' already exists. ` } if (fileTagNos.has(tagNo)) { errorMsg += `TagNo '${tagNo}' is duplicated within this file. ` } else { fileTagNos.add(tagNo) } } if (tagNo && classVal && !errorMsg) { const classValidationError = await validateTagNumberByClass(tagNo, classVal) if (classValidationError) { errorMsg += classValidationError + " " } } if (errorMsg) { row.getCell(lastColIndex).value = errorMsg.trim() errorCount++ } else { const finalTagType = getTagTypeCodeByClassLabel(classVal) ?? "" importedRows.push({ id: 0, packageCode: packageCode, projectCode: projectCode, formId: null, tagNo, tagType: finalTagType, class: classVal, description: String(rowVals[accessorIndexMap["description"] ?? 0] ?? "").trim(), createdAt: new Date(), updatedAt: new Date(), }) } } if (errorCount > 0) { const outBuf = await workbook.xlsx.writeBuffer() const errorFile = new Blob([outBuf]) const url = URL.createObjectURL(errorFile) const link = document.createElement("a") link.href = url link.download = "tag_import_errors.xlsx" link.click() URL.revokeObjectURL(url) toast.error(`There are ${errorCount} error row(s). Please see downloaded file.`) return } if (importedRows.length > 0) { const result = await bulkCreateTags(importedRows, projectCode, packageCode) if ("error" in result) { toast.error(result.error) } else { toast.success(`${result.data.createdCount}개의 태그가 성공적으로 생성되었습니다.`) router.refresh() } } } catch (err) { console.error(err) toast.error("파일 업로드 중 오류가 발생했습니다.") } finally { setIsPending(false) } } // Export 함수 const handleExport = async () => { if (!tableRef.current) { toast.error("테이블이 준비되지 않았습니다.") return } try { setIsExporting(true) await exportTagsToExcel(tableRef.current, packageCode, projectCode, { filename: `Tags_${packageCode}_${projectCode}`, excludeColumns: ["select", "actions", "createdAt", "updatedAt"], }) toast.success("태그 목록이 성공적으로 내보내졌습니다.") } catch (error) { console.error("Export error:", error) toast.error("태그 목록 내보내기 중 오류가 발생했습니다.") } finally { setIsExporting(false) } } // Sync 함수 const startGetTags = async () => { try { setIsSyncing(true) const response = await fetch('/api/cron/tags-plant/start', { method: 'POST', body: JSON.stringify({ projectCode: projectCode, packageCode: packageCode, mode: selectedMode }) }) if (!response.ok) { const errorData = await response.json() throw new Error(errorData.error || 'Failed to start tag import') } const data = await response.json() if (data.syncId) { setSyncId(data.syncId) toast.info('Tag import started. This may take a while...') startPolling(data.syncId) } else { throw new Error('No import ID returned from server') } } catch (error) { console.error('Error starting tag import:', error) toast.error( error instanceof Error ? error.message : 'An error occurred while starting tag import' ) setIsSyncing(false) } } const startPolling = (id: string) => { if (pollingRef.current) { clearInterval(pollingRef.current) } pollingRef.current = setInterval(async () => { try { const response = await fetch(`/api/cron/tags-plant/status?id=${id}`) if (!response.ok) { throw new Error('Failed to get tag import status') } const data = await response.json() if (data.status === 'completed') { if (pollingRef.current) { clearInterval(pollingRef.current) pollingRef.current = null } router.refresh() setIsSyncing(false) setSyncId(null) toast.success( `Tags imported successfully! ${data.result?.processedCount || 0} items processed.` ) } else if (data.status === 'failed') { if (pollingRef.current) { clearInterval(pollingRef.current) pollingRef.current = null } setIsSyncing(false) setSyncId(null) toast.error(data.error || 'Import failed') } } catch (error) { console.error('Error checking importing status:', error) } }, 5000) } // rowAction 처리 React.useEffect(() => { if (rowAction?.type === "delete") { handleDeleteRow(rowAction.row.original) setRowAction(null) } }, [rowAction, handleDeleteRow]) // Cleanup React.useEffect(() => { return () => { if (pollingRef.current) { clearInterval(pollingRef.current) } } }, []) // 로딩 중 if (isLoading) { return (
) } return ( <> { tableRef.current = table }} >
{/* 삭제 버튼 - 선택된 항목이 있을 때만 */} {selectedRowCount > 0 && ( )} {/* Get Tags 버튼 */} {/* Add Tag 버튼 */} {/* Import 버튼 */} {/* Export 버튼 */}
{/* Hidden file input */} {/* Update Sheet */} { if (!open) setRowAction(null) }} tag={rowAction?.row.original ?? null} packageCode={packageCode} projectCode={projectCode} onUpdateSuccess={(updatedValues) => { if (rowAction?.row.original?.tagNo) { const tagNo = rowAction.row.original.tagNo setTableData(prev => prev.map(item => item.tagNo === tagNo ? updatedValues : item ) ) } }} /> {/* Delete Dialog */} { if (!open) { setDeleteDialogOpen(false) setDeleteTarget([]) } }} onSuccess={handleDeleteSuccess} showTrigger={false} /> {/* Add Tag Dialog */} {/* { router.refresh() }} /> */} ) }