"use client" import * as React from "react" import { type Table } from "@tanstack/react-table" import { toast } from "sonner" import ExcelJS from "exceljs" import { saveAs } from "file-saver" import { Button } from "@/components/ui/button" import { Download, Upload, Loader2, RefreshCcw } from "lucide-react" import { Tag, TagSubfields } from "@/db/schema/vendorData" import { exportTagsToExcel } from "./tags-export" import { AddTagDialog } from "./add-tag-dialog" import { fetchTagSubfieldOptions, getTagNumberingRules, } from "@/lib/tag-numbering/service" import { bulkCreateTags, getClassOptions, getProjectIdFromContractItemId, getSubfieldsByTagType } from "../service" import { DeleteTagsDialog } from "./delete-tags-dialog" import { useRouter } from "next/navigation" // Add this import import { decryptWithServerAction } from "@/components/drm/drmUtils" // 태그 번호 검증을 위한 인터페이스 interface TagNumberingRule { attributesId: string; attributesDescription: string; expression: string | null; delimiter: string | null; sortOrder: number; } interface TagOption { code: string; label: string; } 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; } interface TagsTableToolbarActionsProps { /** react-table 객체 */ table: Table /** 현재 선택된 패키지 ID */ packageCode: string projectCode: string /** 현재 태그 목록(상태) */ tableData: Tag[] /** 태그 목록을 갱신하는 setState */ selectedMode: string } /** * TagsTableToolbarActions: * - Import 버튼 -> Excel 파일 파싱 & 유효성 검사 (Class 기반 검증 추가) * - 에러 발생 시: state는 그대로 두고, 오류가 적힌 엑셀만 재다운로드 * - 정상인 경우: tableData에 병합 * - Export 버튼 -> 유효성 검사가 포함된 Excel 내보내기 */ export function TagsTableToolbarActions({ table, packageCode, projectCode, tableData, selectedMode }: TagsTableToolbarActionsProps) { const router = useRouter() // Add this line const [isPending, setIsPending] = React.useState(false) const [isExporting, setIsExporting] = React.useState(false) const fileInputRef = React.useRef(null) const [isLoading, setIsLoading] = React.useState(false) const [syncId, setSyncId] = React.useState(null) const pollingRef = React.useRef(null) // 태그 타입별 넘버링 룰 캐시 const [tagNumberingRules, setTagNumberingRules] = React.useState>({}) const [tagOptionsCache, setTagOptionsCache] = React.useState>({}) // 클래스 옵션 및 서브필드 캐시 const [classOptions, setClassOptions] = React.useState([]) const [subfieldCache, setSubfieldCache] = React.useState>({}) // 컴포넌트 마운트 시 클래스 옵션 로드 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]) // 숨겨진 을 클릭 function handleImportClick() { fileInputRef.current?.click() } // 태그 넘버링 룰 가져오기 const fetchTagNumberingRules = React.useCallback(async (tagType: string): Promise => { // 이미 캐시에 있으면 캐시된 값 사용 if (tagNumberingRules[tagType]) { return tagNumberingRules[tagType] } try { // 서버 액션 직접 호출 const rules = await getTagNumberingRules(tagType) // 캐시에 저장 setTagNumberingRules(prev => ({ ...prev, [tagType]: rules })) return rules } catch (error) { console.error(`Error fetching rules for ${tagType}:`, error) return [] } }, [tagNumberingRules]) const [projectId, setProjectId] = React.useState(null); 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); toast.error("Failed to load project data"); } } }; fetchProjectId(); }, [projectCode]); // 특정 attributesId에 대한 옵션 가져오기 const fetchOptions = React.useCallback(async (attributesId: string): Promise => { // Cache check remains the same if (tagOptionsCache[attributesId]) { return tagOptionsCache[attributesId]; } try { // Only pass projectId if it's not null let options: TagOption[]; if (projectId !== null) { options = await fetchTagSubfieldOptions(attributesId, projectId); } else { options = [] } // Update cache setTagOptionsCache(prev => ({ ...prev, [attributesId]: options })); return options; } catch (error) { console.error(`Error fetching options for ${attributesId}:`, error); return []; } }, [tagOptionsCache, projectId]); // 클래스 라벨로 태그 타입 코드 찾기 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) // API 응답을 SubFieldDef 형식으로 변환 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]) // 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 { // 1. 클래스 라벨로 태그 타입 코드 찾기 const tagTypeCode = getTagTypeCodeByClassLabel(classLabel) if (!tagTypeCode) { return `No tag type found for class '${classLabel}'.` } // 2. 태그 타입 코드로 서브필드 가져오기 const subfields = await fetchSubfieldsByTagType(tagTypeCode) if (!subfields || subfields.length === 0) { return `No subfields found for tag type code '${tagTypeCode}'.` } // 3. 태그 번호를 파트별로 분석 let remainingTagNo = tagNo let currentPosition = 0 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; // 시작과 끝의 ^, $ 제거 cleanPattern = cleanPattern.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]) // 기존 태그 번호 검증 함수 (기존 코드를 유지) const validateTagNumber = React.useCallback(async (tagNo: string, tagType: string): Promise => { if (!tagNo) return "Tag number is empty." if (!tagType) return "Tag type is empty." try { // 1. 태그 타입에 대한 넘버링 룰 가져오기 const rules = await fetchTagNumberingRules(tagType) if (!rules || rules.length === 0) { return `No numbering rules found for tag type '${tagType}'.` } // 2. 정렬된 룰 (sortOrder 기준) const sortedRules = [...rules].sort((a, b) => a.sortOrder - b.sortOrder) // 3. 태그 번호를 파트로 분리 let remainingTagNo = tagNo let currentPosition = 0 for (const rule of sortedRules) { // 마지막 룰이 아니고 구분자가 있으면 const delimiter = rule.delimiter || "" // 다음 구분자 위치 찾기 또는 문자열 끝 let nextDelimiterPos if (delimiter && remainingTagNo.includes(delimiter)) { nextDelimiterPos = remainingTagNo.indexOf(delimiter) } else { nextDelimiterPos = remainingTagNo.length } // 현재 파트 추출 const part = remainingTagNo.substring(0, nextDelimiterPos) // 표현식이 있으면 검증 if (rule.expression) { const regex = new RegExp(`^${rule.expression}$`) if (!regex.test(part)) { return `Part '${part}' does not match the pattern '${rule.expression}' for ${rule.attributesDescription}.` } } // 옵션이 있는 경우 유효한 코드인지 확인 const options = await fetchOptions(rule.attributesId) if (options.length > 0) { const isValidCode = options.some(opt => opt.code === part) if (!isValidCode) { return `'${part}' is not a valid code for ${rule.attributesDescription}. Valid options: ${options.map(o => o.code).join(', ')}.` } } // 남은 문자열 업데이트 if (delimiter && nextDelimiterPos < remainingTagNo.length) { remainingTagNo = remainingTagNo.substring(nextDelimiterPos + delimiter.length) } else { remainingTagNo = "" break } // 모든 룰을 처리했는데 문자열이 남아있으면 오류 if (remainingTagNo && rule === sortedRules[sortedRules.length - 1]) { return `Tag number has extra parts: '${remainingTagNo}'.` } } // 문자열이 남아있으면 오류 if (remainingTagNo) { return `Tag number has unprocessed parts: '${remainingTagNo}'.` } return "" // 오류 없음 } catch (error) { console.error("Error validating tag number:", error) return "Error validating tag number." } }, [fetchTagNumberingRules, fetchOptions]) /** * 개선된 handleFileChange 함수 * 1) ExcelJS로 파일 파싱 * 2) 헤더 -> meta.excelHeader 매핑 * 3) 각 행 유효성 검사 (Class 기반 검증 추가) * 4) 에러 행 있으면 → 오류 메시지 기록 + 재다운로드 (상태 변경 안 함) * 5) 정상 행만 importedRows 로 → 병합 */ async function handleFileChange(e: React.ChangeEvent) { const file = e.target.files?.[0] if (!file) return // 파일 input 초기화 e.target.value = "" setIsPending(true) try { // 1) Workbook 로드 const workbook = new ExcelJS.Workbook() // const arrayBuffer = await file.arrayBuffer() const arrayBuffer = await decryptWithServerAction(file); await workbook.xlsx.load(arrayBuffer) // 첫 번째 시트 사용 const worksheet = workbook.worksheets[0] // (A) 마지막 열에 "Error" 헤더 const lastColIndex = worksheet.columnCount + 1 worksheet.getRow(1).getCell(lastColIndex).value = "Error" // (B) 엑셀 헤더 (Row1) const headerRowValues = worksheet.getRow(1).values as ExcelJS.CellValue[] // (C) excelHeader -> accessor 매핑 const excelHeaderToAccessor: Record = {} for (const col of table.getAllColumns()) { const meta = col.columnDef.meta as { excelHeader?: string } | undefined if (meta?.excelHeader) { const accessor = col.id as string excelHeaderToAccessor[meta.excelHeader] = accessor } } // (D) accessor -> column index 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 // 2) 각 데이터 행 파싱 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 = "" // 필요한 accessorIndex const tagNoIndex = accessorIndexMap["tagNo"] const classIndex = accessorIndexMap["class"] // 엑셀에서 값 읽기 const tagNo = tagNoIndex ? String(rowVals[tagNoIndex] ?? "").trim() : "" const classVal = classIndex ? String(rowVals[classIndex] ?? "").trim() : "" // A. 필수값 검사 if (!tagNo) { errorMsg += `Tag No is empty. ` } if (!classVal) { errorMsg += `Class is empty. ` } // B. 중복 검사 if (tagNo) { // 이미 tableData 내 존재 여부 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) } } // C. Class 기반 형식 검증 if (tagNo && classVal && !errorMsg) { // classVal 로부터 태그타입 코드 획득 const tagTypeCode = getTagTypeCodeByClassLabel(classVal) if (!tagTypeCode) { errorMsg += `No tag type code found for class '${classVal}'. ` } else { // validateTagNumberByClass( ) 안에서 // → tagTypeCode로 서브필드 조회, 정규식 검증 등 처리 const classValidationError = await validateTagNumberByClass(tagNo, classVal) if (classValidationError) { errorMsg += classValidationError + " " } } } // D. 에러 처리 if (errorMsg) { row.getCell(lastColIndex).value = errorMsg.trim() errorCount++ } else { // 최종 태그 타입 결정 (DB에 저장할 때 'tagType' 컬럼을 무엇으로 쓸지 결정) // 예: DB에서 tagType을 "CV" 같은 코드로 저장하려면 // const finalTagType = getTagTypeCodeByClassLabel(classVal) ?? "" // 혹은 "Control Valve" 같은 description을 쓰려면 classOptions에서 찾아볼 수도 있음 const finalTagType = getTagTypeCodeByClassLabel(classVal) ?? "" // 정상 행을 importedRows에 추가 importedRows.push({ id: 0, // 임시 packageCode: packageCode, projectCode: projectCode, formId: null, tagNo, tagType: finalTagType, // ← 코드로 저장할지, Description으로 저장할지 결정 class: classVal, description: String(rowVals[accessorIndexMap["description"] ?? 0] ?? "").trim(), createdAt: new Date(), updatedAt: new Date(), }) } } // (E) 오류 행이 있으면 → 수정된 엑셀 재다운로드 & 종료 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}개의 태그가 성공적으로 생성되었습니다.`); } } toast.success(`Imported ${importedRows.length} tags successfully!`) } catch (err) { console.error(err) toast.error("파일 업로드 중 오류가 발생했습니다.") } finally { setIsPending(false) } } // 새 Export 함수 - 유효성 검사 시트를 포함한 엑셀 내보내기 async function handleExport() { try { setIsExporting(true) // 유효성 검사가 포함된 새로운 엑셀 내보내기 함수 호출 await exportTagsToExcel(table, 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) } } const startGetTags = async () => { try { setIsLoading(true) // API 엔드포인트 호출 - 작업 시작만 요청 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() // 작업 ID 저장 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' ) setIsLoading(false) } } const startPolling = (id: string) => { // 이전 폴링이 있다면 제거 if (pollingRef.current) { clearInterval(pollingRef.current) } // 5초마다 상태 확인 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() // 상태 초기화 setIsLoading(false) setSyncId(null) // 성공 메시지 표시 toast.success( `Tags imported successfully! ${data.result?.processedCount || 0} items processed.` ) // 테이블 데이터 업데이트 table.resetRowSelection() } else if (data.status === 'failed') { // 에러 처리 if (pollingRef.current) { clearInterval(pollingRef.current) pollingRef.current = null } setIsLoading(false) setSyncId(null) toast.error(data.error || 'Import failed') } else if (data.status === 'processing') { // 진행 상태 업데이트 (선택적) if (data.progress) { toast.info(`Import in progress: ${data.progress}%`, { id: `import-progress-${id}`, }) } } } catch (error) { console.error('Error checking importing status:', error) } }, 5000) // 5초마다 체크 } return (
{table.getFilteredSelectedRowModel().rows.length > 0 ? ( row.original)} onSuccess={() => table.toggleAllRowsSelected(false)} projectCode={projectCode} packageCode={packageCode} /> ) : null} {/* Import */} {/* Export */}
) }