"use client" import * as React from "react" import { useForm } from "react-hook-form" import { zodResolver } from "@hookform/resolvers/zod" import { z } from "zod" import { toast } from "sonner" import { useRouter } from "next/navigation" import { useSession } from "next-auth/react" import ExcelJS from 'exceljs' import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, } from "@/components/ui/dialog" import { Button } from "@/components/ui/button" import { Input } from "@/components/ui/input" import { Textarea } from "@/components/ui/textarea" import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage, } from "@/components/ui/form" import { Dropzone, DropzoneDescription, DropzoneInput, DropzoneTitle, DropzoneUploadIcon, DropzoneZone, } from "@/components/ui/dropzone" import { FileList, FileListAction, FileListHeader, FileListIcon, FileListInfo, FileListItem, FileListName, FileListSize, } from "@/components/ui/file-list" import { Badge } from "@/components/ui/badge" import { ScrollArea } from "@/components/ui/scroll-area" import { Separator } from "@/components/ui/separator" import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card" import { Upload, X, Loader2, FileSpreadsheet, Files, CheckCircle2, AlertCircle, Download } from "lucide-react" import prettyBytes from "pretty-bytes" import type { EnhancedDocument } from "@/types/enhanced-documents" // 일괄 업로드 스키마 const bulkUploadSchema = z.object({ uploaderName: z.string().optional(), comment: z.string().optional(), templateFile: z.instanceof(File).optional(), attachmentFiles: z.array(z.instanceof(File)).min(1, "최소 1개 파일이 필요합니다"), }) type BulkUploadSchema = z.infer interface ParsedUploadItem { documentId: number docNumber: string title: string stage: string revision: string fileNames: string[] // ';'로 구분된 파일명들 } interface FileMatchResult { matched: { file: File; item: ParsedUploadItem }[] unmatched: File[] missingFiles: string[] } interface BulkUploadDialogProps { open: boolean onOpenChange: (open: boolean) => void documents: EnhancedDocument[] projectType: "ship" | "plant" contractId: number // ✅ contractId 추가 } export function BulkUploadDialog({ open, onOpenChange, documents, projectType, contractId, // ✅ contractId 받기 }: BulkUploadDialogProps) { const [selectedFiles, setSelectedFiles] = React.useState([]) const [templateFile, setTemplateFile] = React.useState(null) const [parsedData, setParsedData] = React.useState([]) const [matchResult, setMatchResult] = React.useState(null) const [isUploading, setIsUploading] = React.useState(false) const [uploadProgress, setUploadProgress] = React.useState(0) const [currentStep, setCurrentStep] = React.useState<'template' | 'files' | 'review' | 'upload'>('template') const router = useRouter() const { data: session } = useSession() const form = useForm({ resolver: zodResolver(bulkUploadSchema), defaultValues: { uploaderName: session?.user?.name || "", comment: "", templateFile: undefined, attachmentFiles: [], }, }) React.useEffect(() => { if (session?.user?.name) { form.setValue('uploaderName', session.user.name) } }, [session?.user?.name, form]) // 다이얼로그가 열릴 때마다 업로더명 리프레시 React.useEffect(() => { if (open && session?.user?.name) { form.setValue('uploaderName', session.user.name) } }, [open, session?.user?.name, form]) // 리비전 정렬 및 최신 리비전 찾기 헬퍼 함수들 const compareRevisions = (a: string, b: string): number => { // 알파벳 리비전 (A, B, C, ..., Z, AA, AB, ...) const aIsAlpha = /^[A-Z]+$/.test(a) const bIsAlpha = /^[A-Z]+$/.test(b) if (aIsAlpha && bIsAlpha) { // 길이 먼저 비교 (A < AA) if (a.length !== b.length) { return a.length - b.length } // 같은 길이면 알파벳 순서 return a.localeCompare(b) } // 숫자 리비전 (0, 1, 2, ...) const aIsNumber = /^\d+$/.test(a) const bIsNumber = /^\d+$/.test(b) if (aIsNumber && bIsNumber) { return parseInt(a) - parseInt(b) } // 혼재된 경우 알파벳이 먼저 if (aIsAlpha && bIsNumber) return -1 if (aIsNumber && bIsAlpha) return 1 // 기타 복잡한 형태는 문자열 비교 return a.localeCompare(b) } const getLatestRevisionInStage = (document: EnhancedDocument, stageName: string): string => { const stage = document.allStages?.find(s => s.stageName === stageName) if (!stage || !stage.revisions || stage.revisions.length === 0) { return '' } // 리비전들을 정렬해서 최신 것 찾기 const sortedRevisions = [...stage.revisions].sort((a, b) => compareRevisions(a.revision, b.revision) ) return sortedRevisions[sortedRevisions.length - 1]?.revision || '' } const getNextRevision = (currentRevision: string): string => { if (!currentRevision) return "A" // 알파벳 리비전 (A, B, C...) if (/^[A-Z]+$/.test(currentRevision)) { // 한 글자인 경우 if (currentRevision.length === 1) { const charCode = currentRevision.charCodeAt(0) if (charCode < 90) { // Z가 아닌 경우 return String.fromCharCode(charCode + 1) } return "AA" // Z 다음은 AA } // 여러 글자인 경우 (AA, AB, ... AZ, BA, ...) let result = currentRevision let carry = true let newResult = '' for (let i = result.length - 1; i >= 0 && carry; i--) { let charCode = result.charCodeAt(i) if (charCode < 90) { // Z가 아닌 경우 newResult = String.fromCharCode(charCode + 1) + newResult carry = false } else { // Z인 경우 newResult = 'A' + newResult } } if (carry) { newResult = 'A' + newResult } else { newResult = result.substring(0, result.length - newResult.length) + newResult } return newResult } // 숫자 리비전 (0, 1, 2...) if (/^\d+$/.test(currentRevision)) { return String(parseInt(currentRevision) + 1) } // 기타 복잡한 리비전 형태는 그대로 반환 return currentRevision } // 템플릿 export 함수 const exportTemplate = async () => { try { const workbook = new ExcelJS.Workbook() const worksheet = workbook.addWorksheet('BulkUploadTemplate') // 헤더 정의 const headers = [ 'documentId', 'docNumber', 'title', 'currentStage', 'latestRevision', 'targetStage', 'targetRevision', 'fileNames' ] // 헤더 스타일링 const headerRow = worksheet.addRow(headers) headerRow.eachCell((cell, colNumber) => { cell.font = { bold: true, color: { argb: 'FFFFFF' } } cell.fill = { type: 'pattern', pattern: 'solid', fgColor: { argb: '366092' } } cell.border = { top: { style: 'thin' }, left: { style: 'thin' }, bottom: { style: 'thin' }, right: { style: 'thin' } } cell.alignment = { horizontal: 'center', vertical: 'middle' } }) // 데이터 추가 documents.forEach(doc => { const currentStageName = doc.currentStageName || '' const latestRevision = getLatestRevisionInStage(doc, currentStageName) const row = worksheet.addRow([ doc.documentId, doc.docNumber, doc.title, currentStageName, latestRevision, // 현재 스테이지의 최신 리비전 currentStageName, // 기본값으로 현재 스테이지 설정 latestRevision, // 기본값으로 현재 최신 리비전 설정 (사용자가 선택) '', // 사용자가 입력할 파일명들 (';'로 구분) ]) // 데이터 행 스타일링 row.eachCell((cell, colNumber) => { cell.border = { top: { style: 'thin' }, left: { style: 'thin' }, bottom: { style: 'thin' }, right: { style: 'thin' } } // 편집 가능한 칼럼 (targetStage, targetRevision, fileNames) 강조 if (colNumber >= 6) { cell.fill = { type: 'pattern', pattern: 'solid', fgColor: { argb: 'FFF2CC' } // 연한 노란색 } } }) }) // 칼럼 너비 설정 worksheet.columns = [ { width: 12 }, // documentId { width: 18 }, // docNumber { width: 35 }, // title { width: 20 }, // currentStage { width: 15 }, // latestRevision { width: 20 }, // targetStage { width: 15 }, // targetRevision { width: 60 }, // fileNames ] // 헤더 고정 worksheet.views = [{ state: 'frozen', ySplit: 1 }] // 주석 추가 const instructionRow = worksheet.insertRow(1, [ '지침:', '1. latestRevision: 현재 스테이지의 최신 리비전', '2. targetStage: 업로드할 스테이지명 (수정 가능)', '3. targetRevision: 같은 리비전에 파일 추가 시 그대로, 새 리비전 생성 시 수정', '4. fileNames: 파일명들을 세미콜론(;)으로 구분', '예: file1.pdf;file2.dwg;file3.xlsx', '', '← 이 행은 삭제하고 사용해도 됩니다' ]) instructionRow.eachCell((cell) => { cell.font = { italic: true, color: { argb: '888888' } } cell.fill = { type: 'pattern', pattern: 'solid', fgColor: { argb: 'F0F0F0' } } }) // 파일 다운로드 const buffer = await workbook.xlsx.writeBuffer() const blob = new Blob([buffer], { type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' }) const url = window.URL.createObjectURL(blob) const link = document.createElement('a') link.href = url link.download = `bulk-upload-template-${new Date().toISOString().split('T')[0]}.xlsx` document.body.appendChild(link) link.click() document.body.removeChild(link) window.URL.revokeObjectURL(url) toast.success("템플릿이 다운로드되었습니다. targetRevision은 기본값(최신 리비전)이 설정되어 있습니다!") } catch (error) { console.error('템플릿 생성 오류:', error) toast.error('템플릿 생성에 실패했습니다.') } } // 템플릿 파일 파싱 const parseTemplateFile = async (file: File) => { try { const arrayBuffer = await file.arrayBuffer() const workbook = new ExcelJS.Workbook() await workbook.xlsx.load(arrayBuffer) const worksheet = workbook.getWorksheet(1) // 첫 번째 워크시트 if (!worksheet) { throw new Error('워크시트를 찾을 수 없습니다.') } // 헤더 행 찾기 (지침 행이 있을 수 있으므로) let headerRowIndex = 1 let headers: string[] = [] // 최대 5행까지 헤더를 찾아본다 for (let i = 1; i <= 5; i++) { const row = worksheet.getRow(i) const firstCell = row.getCell(1).value if (firstCell && String(firstCell).includes('documentId')) { headerRowIndex = i headers = [] // 헤더 추출 for (let col = 1; col <= 8; col++) { const cellValue = row.getCell(col).value headers.push(String(cellValue || '')) } break } } const expectedHeaders = ['documentId', 'docNumber', 'title', 'currentStage', 'latestRevision', 'targetStage', 'targetRevision', 'fileNames'] const missingHeaders = expectedHeaders.filter(h => !headers.includes(h)) if (missingHeaders.length > 0) { throw new Error(`필수 칼럼이 누락되었습니다: ${missingHeaders.join(', ')}`) } // 데이터 파싱 const parsed: ParsedUploadItem[] = [] const rowCount = worksheet.rowCount console.log(`📊 파싱 시작: 총 ${rowCount}행, 헤더 행: ${headerRowIndex}`) for (let i = headerRowIndex + 1; i <= rowCount; i++) { const row = worksheet.getRow(i) // 빈 행 스킵 if (!row.hasValues) { console.log(`행 ${i}: 빈 행 스킵`) continue } const documentIdCell = row.getCell(headers.indexOf('documentId') + 1).value const docNumberCell = row.getCell(headers.indexOf('docNumber') + 1).value const titleCell = row.getCell(headers.indexOf('title') + 1).value const stageCell = row.getCell(headers.indexOf('targetStage') + 1).value const revisionCell = row.getCell(headers.indexOf('targetRevision') + 1).value const fileNamesCell = row.getCell(headers.indexOf('fileNames') + 1).value // 값들을 안전하게 변환 const documentId = Number(documentIdCell) || 0 const docNumber = String(docNumberCell || '').trim() const title = String(titleCell || '').trim() const stage = String(stageCell || '').trim().replace(/[ \s]/g, ' ').trim() // 전각공백 처리 const revision = String(revisionCell || '').trim() const fileNamesStr = String(fileNamesCell || '').trim().replace(/[ \s]/g, ' ').trim() // 전각공백 처리 console.log(`행 ${i} 파싱 결과:`, { documentId, docNumber, title, stage, revision, fileNamesStr, originalCells: { documentIdCell, docNumberCell, titleCell, stageCell, revisionCell, fileNamesCell } }) // 필수 데이터 체크 (documentId와 docNumber만 체크, stage와 revision은 빈 값 허용) if (!documentId || !docNumber) { console.warn(`행 ${i}: 필수 데이터 누락 (documentId: ${documentId}, docNumber: ${docNumber})`) continue } // fileNames가 비어있는 행은 무시 if (!fileNamesStr || fileNamesStr === '' || fileNamesStr === 'undefined' || fileNamesStr === 'null') { console.log(`행 ${i}: fileNames가 비어있어 스킵합니다. (${docNumber}) - fileNamesStr: "${fileNamesStr}"`) continue } // stage와 revision이 비어있는 경우 기본값 설정 const finalStage = stage || 'Default Stage' const finalRevision = revision || 'A' const fileNames = fileNamesStr.split(';').map(name => name.trim()).filter(Boolean) if (fileNames.length === 0) { console.warn(`행 ${i}: 파일명 파싱 실패 (${docNumber}) - 원본: "${fileNamesStr}"`) continue } console.log(`✅ 행 ${i} 파싱 성공:`, { documentId, docNumber, stage: finalStage, revision: finalRevision, fileNames }) parsed.push({ documentId, docNumber, title, stage: finalStage, revision: finalRevision, fileNames, }) } console.log(`📋 파싱 완료: ${parsed.length}개 항목`) if (parsed.length === 0) { console.error('파싱된 데이터:', parsed) throw new Error('파싱할 수 있는 유효한 데이터가 없습니다. fileNames 칼럼에 파일명이 입력되어 있는지 확인해주세요.') } setParsedData(parsed) setCurrentStep('files') toast.success(`템플릿 파싱 완료: ${parsed.length}개 항목, 총 ${parsed.reduce((sum, item) => sum + item.fileNames.length, 0)}개 파일 필요`) } catch (error) { console.error('템플릿 파싱 오류:', error) toast.error(error instanceof Error ? error.message : '템플릿 파싱에 실패했습니다.') } } // 파일 매칭 로직 const matchFiles = (files: File[], uploadItems: ParsedUploadItem[]): FileMatchResult => { const matched: { file: File; item: ParsedUploadItem }[] = [] const unmatched: File[] = [] const missingFiles: string[] = [] // 모든 필요한 파일명 수집 const requiredFileNames = new Set() uploadItems.forEach(item => { item.fileNames.forEach(fileName => requiredFileNames.add(fileName)) }) // 파일 매칭 files.forEach(file => { let isMatched = false for (const item of uploadItems) { if (item.fileNames.some(fileName => fileName === file.name)) { matched.push({ file, item }) isMatched = true break } } if (!isMatched) { unmatched.push(file) } }) // 누락된 파일 찾기 const uploadedFileNames = new Set(files.map(f => f.name)) requiredFileNames.forEach(fileName => { if (!uploadedFileNames.has(fileName)) { missingFiles.push(fileName) } }) return { matched, unmatched, missingFiles } } // 템플릿 드롭 처리 const handleTemplateDropAccepted = (acceptedFiles: File[]) => { const file = acceptedFiles[0] if (!file) return if (!file.name.endsWith('.xlsx') && !file.name.endsWith('.xls')) { toast.error('Excel 파일(.xlsx, .xls)만 업로드 가능합니다.') return } setTemplateFile(file) form.setValue('templateFile', file) parseTemplateFile(file) } // 파일 드롭 처리 const handleFilesDropAccepted = (acceptedFiles: File[]) => { const newFiles = [...selectedFiles, ...acceptedFiles] setSelectedFiles(newFiles) form.setValue('attachmentFiles', newFiles, { shouldValidate: true }) // 파일 매칭 수행 if (parsedData.length > 0) { const result = matchFiles(newFiles, parsedData) setMatchResult(result) setCurrentStep('review') } } // 파일 제거 const removeFile = (index: number) => { const updatedFiles = [...selectedFiles] updatedFiles.splice(index, 1) setSelectedFiles(updatedFiles) form.setValue('attachmentFiles', updatedFiles, { shouldValidate: true }) if (parsedData.length > 0) { const result = matchFiles(updatedFiles, parsedData) setMatchResult(result) } } // 일괄 업로드 처리 const onSubmit = async (data: BulkUploadSchema) => { if (!matchResult || matchResult.matched.length === 0) { toast.error('매칭된 파일이 없습니다.') return } setIsUploading(true) setUploadProgress(0) setCurrentStep('upload') try { const formData = new FormData() // 메타데이터 formData.append('uploaderName', data.uploaderName || '') formData.append('comment', data.comment || '') formData.append('projectType', projectType) if (contractId) { formData.append('contractId', String(contractId)) // ✅ contractId 추가 } formData.append('contractId', String(contractId)) // ✅ contractId 추가 // 매칭된 파일들과 메타데이터 const uploadData = matchResult.matched.map(({ file, item }) => ({ documentId: item.documentId, stage: item.stage, revision: item.revision, fileName: file.name, })) formData.append('uploadData', JSON.stringify(uploadData)) // 파일들 추가 matchResult.matched.forEach(({ file }, index) => { formData.append(`file_${index}`, file) }) // 진행률 시뮬레이션 const progressInterval = setInterval(() => { setUploadProgress(prev => Math.min(prev + 10, 90)) }, 500) const response = await fetch('/api/bulk-upload', { method: 'POST', body: formData, }) clearInterval(progressInterval) if (!response.ok) { const errorData = await response.json() throw new Error(errorData.error || '일괄 업로드에 실패했습니다.') } const result = await response.json() setUploadProgress(100) toast.success(`${result.data?.uploadedCount || 0}개 파일이 성공적으로 업로드되었습니다.`) setTimeout(() => { handleDialogClose() router.refresh() }, 1000) } catch (error) { console.error('일괄 업로드 오류:', error) toast.error(error instanceof Error ? error.message : "업로드 중 오류가 발생했습니다") } finally { setIsUploading(false) setTimeout(() => setUploadProgress(0), 2000) } } const handleDialogClose = () => { form.reset({ uploaderName: session?.user?.name || "", // ✅ 항상 최신 session 값으로 리셋 comment: "", templateFile: undefined, attachmentFiles: [], }) setSelectedFiles([]) setTemplateFile(null) setParsedData([]) setMatchResult(null) setCurrentStep('template') setIsUploading(false) setUploadProgress(0) onOpenChange(false) } const canProceedToUpload = matchResult && matchResult.matched.length > 0 && matchResult.missingFiles.length === 0 return ( 일괄 업로드 템플릿을 다운로드하여 파일명을 입력한 후, 실제 파일들을 업로드하세요.
{projectType === "ship" ? "조선 프로젝트" : "플랜트 프로젝트"} 총 {documents.length}개 문서
{/* 단계별 진행 상태 */}
{[ { key: 'template', label: '템플릿' }, { key: 'files', label: '파일 업로드' }, { key: 'review', label: '검토' }, { key: 'upload', label: '업로드' }, ].map((step, index) => (
['template', 'files', 'review'].indexOf(step.key) ? 'bg-green-100 text-green-700' : 'bg-gray-100 text-gray-500' }`}> {step.label}
{index < 3 &&
} ))}
{/* 1단계: 템플릿 다운로드 및 업로드 */} {currentStep === 'template' && (
1단계: 템플릿 다운로드

현재 문서 목록을 기반으로 업로드 템플릿을 생성합니다. 마지막 "fileNames" 칼럼에 업로드할 파일명을 ';'로 구분하여 입력하세요.

작성된 템플릿 업로드
작성된 Excel 템플릿을 업로드하세요 .xlsx, .xls 파일을 지원합니다
{templateFile && (
템플릿 업로드 완료: {templateFile.name}
)}
)} {/* 2단계: 파일 업로드 */} {currentStep === 'files' && (
2단계: 실제 파일들 업로드

템플릿에서 {parsedData.length}개 항목, 총 {parsedData.reduce((sum, item) => sum + item.fileNames.length, 0)}개 파일이 필요합니다.

실제 파일들을 여기에 드롭하세요 또는 클릭하여 파일들을 선택하세요
{selectedFiles.length > 0 && (
업로드된 파일 ({selectedFiles.length})
{selectedFiles.map((file, index) => ( {file.name} {prettyBytes(file.size)} removeFile(index)} disabled={isUploading} > ))}
)}
)} {/* 3단계: 매칭 결과 검토 */} {currentStep === 'review' && matchResult && (
3단계: 매칭 결과 검토 {/* 통합된 매칭 결과 요약 */}
{matchResult.matched.length}
매칭 성공
{matchResult.unmatched.length}
매칭 실패
{matchResult.missingFiles.length}
누락된 파일
{/* 통합된 상세 결과 */}
{/* 매칭 성공 섹션 */} {matchResult.matched.length > 0 && (
매칭 성공 ({matchResult.matched.length}개)
{/* 미리보기 */}
{matchResult.matched.slice(0, 5).map((match, index) => (
{match.file.name} → {match.item.docNumber} Rev.{match.item.revision}
))} {matchResult.matched.length > 5 && (
... 외 {matchResult.matched.length - 5}개 (상세보기로 확인)
)}
{/* 펼침 상세 내용 */}
{matchResult.matched.map((match, index) => (
{match.file.name} → {match.item.docNumber} ({match.item.stage} Rev.{match.item.revision})
))}
)} {/* 매칭 실패 섹션 */} {matchResult.unmatched.length > 0 && (
매칭되지 않은 파일 ({matchResult.unmatched.length}개)
{matchResult.unmatched.slice(0, 3).map((file, index) => (
{file.name}
))} {matchResult.unmatched.length > 3 && (
... 외 {matchResult.unmatched.length - 3}개
)}
{matchResult.unmatched.map((file, index) => (
{file.name}
))}
)} {/* 누락된 파일 섹션 */} {matchResult.missingFiles.length > 0 && (
누락된 파일 ({matchResult.missingFiles.length}개)
{matchResult.missingFiles.slice(0, 3).map((fileName, index) => (
{fileName}
))} {matchResult.missingFiles.length > 3 && (
... 외 {matchResult.missingFiles.length - 3}개
)}
{matchResult.missingFiles.map((fileName, index) => (
{fileName}
))}
)}
{/* 업로드 불가 경고 */} {!canProceedToUpload && (
누락된 파일이 있어 업로드를 진행할 수 없습니다. 누락된 파일들을 추가해주세요.
)}
{/* 추가 정보 입력 */}
( 업로더명 )} /> ( 코멘트 (선택)