"use client" import * as React from "react" import { type Table } from "@tanstack/react-table" import { Download, RefreshCcw, Upload, FileText, Loader2, ChevronDown, X, Play, Pause, RotateCcw, Trash } from "lucide-react" import { Button } from "@/components/ui/button" import { Input } from "@/components/ui/input" import { Label } from "@/components/ui/label" import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, DialogTrigger, } from "@/components/ui/dialog" import { Progress } from "@/components/ui/progress" import { Badge } from "@/components/ui/badge" import { toast } from "sonner" import { OcrRow } from "@/db/schema" import { exportTableToExcel } from "@/lib/export_all" import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuSeparator, DropdownMenuTrigger, } from "@/components/ui/dropdown-menu" import { Dropzone, DropzoneDescription, DropzoneInput, DropzoneTitle, DropzoneUploadIcon, DropzoneZone, } from "@/components/ui/dropzone" import { FileList, FileListAction, FileListDescription, FileListHeader, FileListIcon, FileListInfo, FileListItem, FileListName, FileListSize, } from "@/components/ui/file-list" import { getOcrAllRows } from "../service" import { exportOcrDataToExcel } from "./exporft-ocr-data" import { DeleteOcrRowsDialog } from "./delete-ocr-rows-dialog" interface OcrTableToolbarActionsProps { table: Table } interface UploadProgress { stage: string progress: number message: string } interface FileUploadItem { id: string file: File status: 'pending' | 'processing' | 'completed' | 'failed' progress?: UploadProgress error?: string result?: { totalTables: number totalRows: number sessionId: string } } interface BatchProgress { total: number completed: number failed: number current?: string // 현재 처리 중인 파일명 } export function OcrTableToolbarActions({ table }: OcrTableToolbarActionsProps) { const [isLoading, setIsLoading] = React.useState(false) const [isUploading, setIsUploading] = React.useState(false) const [uploadProgress, setUploadProgress] = React.useState(null) const [isUploadDialogOpen, setIsUploadDialogOpen] = React.useState(false) const [selectedFile, setSelectedFile] = React.useState(null) const fileInputRef = React.useRef(null) const [isExporting, setIsExporting] = React.useState(false) // 멀티 파일 업로드 관련 상태 const [isBatchDialogOpen, setIsBatchDialogOpen] = React.useState(false) const [fileQueue, setFileQueue] = React.useState([]) const [isBatchProcessing, setIsBatchProcessing] = React.useState(false) const [batchProgress, setBatchProgress] = React.useState({ total: 0, completed: 0, failed: 0 }) const [isPaused, setIsPaused] = React.useState(false) const batchControllerRef = React.useRef(null) // 선택된 행들 const selectedRows = table.getFilteredSelectedRowModel().rows // 단일 파일 업로드 다이얼로그 닫기 핸들러 const handleDialogOpenChange = (open: boolean) => { if (!open) { if (isUploading && uploadProgress?.stage !== "complete") { toast.warning("처리 중에는 창을 닫을 수 없습니다. 완료될 때까지 기다려주세요.", { description: "OCR 처리가 진행 중입니다..." }) return } resetUpload() } setIsUploadDialogOpen(open) } // 배치 업로드 다이얼로그 닫기 핸들러 const handleBatchDialogOpenChange = (open: boolean) => { if (!open) { if (isBatchProcessing && !isPaused) { toast.warning("일괄 처리 중에는 창을 닫을 수 없습니다. 먼저 일시정지하세요.", { description: "일괄 OCR 처리가 진행 중입니다..." }) return } resetBatchUpload() } setIsBatchDialogOpen(open) } const handleFileSelect = (event: React.ChangeEvent) => { const file = event.target.files?.[0] if (file) { setSelectedFile(file) } } // 멀티 파일 선택/드롭 핸들러 const handleFilesSelect = (files: FileList | File[]) => { const newFiles = Array.from(files).map(file => ({ id: `${file.name}-${Date.now()}-${Math.random()}`, file, status: 'pending' as const })) // 파일 검증 const validFiles = newFiles.filter(item => { const error = validateFile(item.file) if (error) { toast.error(`${item.file.name}: ${error}`) return false } return true }) setFileQueue(prev => [...prev, ...validFiles]) setBatchProgress(prev => ({ ...prev, total: prev.total + validFiles.length })) if (validFiles.length > 0) { toast.success(`${validFiles.length}개 파일이 대기열에 추가되었습니다`) } } const validateFile = (file: File): string | null => { // 파일 크기 체크 (10MB) if (file.size > 10 * 1024 * 1024) { return "파일 크기는 10MB 미만이어야 합니다" } // 파일 타입 체크 const allowedTypes = [ 'application/pdf', 'image/jpeg', 'image/jpg', 'image/png', 'image/tiff', 'image/bmp' ] if (!allowedTypes.includes(file.type)) { return "PDF 및 이미지 파일(JPG, PNG, TIFF, BMP)만 지원됩니다" } return null } // 단일 파일 업로드 const uploadFile = async () => { if (!selectedFile) { toast.error("먼저 파일을 선택하세요") return } const validationError = validateFile(selectedFile) if (validationError) { toast.error(validationError) return } try { setIsUploading(true) setUploadProgress({ stage: "preparing", progress: 10, message: "파일 업로드 준비 중..." }) const formData = new FormData() formData.append('file', selectedFile) setUploadProgress({ stage: "uploading", progress: 30, message: "파일 업로드 및 처리 중..." }) const response = await fetch('/api/ocr/enhanced', { method: 'POST', body: formData, }) setUploadProgress({ stage: "processing", progress: 70, message: "OCR을 사용하여 문서 분석 중..." }) if (!response.ok) { const errorData = await response.json() throw new Error(errorData.error || 'OCR 처리가 실패했습니다') } const result = await response.json() setUploadProgress({ stage: "saving", progress: 90, message: "결과를 데이터베이스에 저장 중..." }) if (result.success) { setUploadProgress({ stage: "complete", progress: 100, message: "OCR 처리가 성공적으로 완료되었습니다!" }) toast.success( `OCR 완료! ${result.metadata.totalTables}개 테이블에서 ${result.metadata.totalRows}개 행을 추출했습니다`, { description: result.warnings?.length ? `경고: ${result.warnings.join(', ')}` : undefined } ) setTimeout(() => { setIsUploadDialogOpen(false) resetUpload() window.location.reload() }, 2000) } else { throw new Error(result.error || '알 수 없는 오류가 발생했습니다') } } catch (error) { console.error('파일 업로드 오류:', error) toast.error( error instanceof Error ? error.message : '파일 처리 중 오류가 발생했습니다' ) setUploadProgress(null) } finally { setIsUploading(false) } } // 배치 처리 시작 const startBatchProcessing = async () => { const pendingFiles = fileQueue.filter(item => item.status === 'pending') if (pendingFiles.length === 0) { toast.warning("처리할 파일이 없습니다") return } setIsBatchProcessing(true) setIsPaused(false) batchControllerRef.current = new AbortController() let processed = 0 for (const fileItem of pendingFiles) { // 일시정지 체크 if (isPaused) { break } // 중단 체크 if (batchControllerRef.current?.signal.aborted) { break } try { // 파일 상태를 processing으로 변경 setFileQueue(prev => prev.map(item => item.id === fileItem.id ? { ...item, status: 'processing' as const } : item )) setBatchProgress(prev => ({ ...prev, current: fileItem.file.name })) // 개별 파일 처리 const result = await processSingleFileInBatch(fileItem) // 결과에 따라 상태 업데이트 if (result.success) { setFileQueue(prev => prev.map(item => item.id === fileItem.id ? { ...item, status: 'completed' as const, result: { totalTables: result.metadata.totalTables, totalRows: result.metadata.totalRows, sessionId: result.sessionId } } : item )) processed++ } else { throw new Error(result.error || '처리가 실패했습니다') } } catch (error) { // 실패 상태로 변경 setFileQueue(prev => prev.map(item => item.id === fileItem.id ? { ...item, status: 'failed' as const, error: error instanceof Error ? error.message : '알 수 없는 오류' } : item )) setBatchProgress(prev => ({ ...prev, failed: prev.failed + 1 })) } setBatchProgress(prev => ({ ...prev, completed: prev.completed + 1 })) // 다음 파일 처리 전 잠시 대기 (API 부하 방지) await new Promise(resolve => setTimeout(resolve, 1000)) } setIsBatchProcessing(false) setBatchProgress(prev => ({ ...prev, current: undefined })) const completedCount = fileQueue.filter(item => item.status === 'completed').length const failedCount = fileQueue.filter(item => item.status === 'failed').length toast.success( `일괄 처리 완료! ${completedCount}개 성공, ${failedCount}개 실패`, { description: "이제 테이블을 새로고침하여 새 데이터를 확인할 수 있습니다" } ) } // 개별 파일 처리 (배치 내에서) const processSingleFileInBatch = async (fileItem: FileUploadItem) => { const formData = new FormData() formData.append('file', fileItem.file) const response = await fetch('/api/ocr/enhanced', { method: 'POST', body: formData, signal: batchControllerRef.current?.signal }) if (!response.ok) { const errorData = await response.json() throw new Error(errorData.error || 'OCR 처리가 실패했습니다') } return await response.json() } // 배치 처리 일시정지/재개 const toggleBatchPause = () => { setIsPaused(prev => !prev) if (isPaused) { toast.info("일괄 처리가 재개되었습니다") } else { toast.info("현재 파일 처리 후 일시정지됩니다") } } // 배치 처리 중단 const stopBatchProcessing = () => { batchControllerRef.current?.abort() setIsBatchProcessing(false) setIsPaused(false) setBatchProgress(prev => ({ ...prev, current: undefined })) toast.info("일괄 처리가 중단되었습니다") } // 파일 큐에서 제거 const removeFileFromQueue = (fileId: string) => { setFileQueue(prev => { const newQueue = prev.filter(item => item.id !== fileId) const removedItem = prev.find(item => item.id === fileId) if (removedItem?.status === 'pending') { setBatchProgress(prevProgress => ({ ...prevProgress, total: prevProgress.total - 1 })) } return newQueue }) } // 실패한 파일들 재시도 const retryFailedFiles = () => { setFileQueue(prev => prev.map(item => item.status === 'failed' ? { ...item, status: 'pending' as const, error: undefined } : item )) const failedCount = fileQueue.filter(item => item.status === 'failed').length setBatchProgress(prev => ({ ...prev, failed: 0, total: prev.total + failedCount })) toast.success(`${failedCount}개의 실패한 파일이 대기열에 다시 추가되었습니다`) } // 완료된 파일들 제거 const clearCompletedFiles = () => { const completedCount = fileQueue.filter(item => item.status === 'completed').length setFileQueue(prev => prev.filter(item => item.status !== 'completed')) setBatchProgress(prev => ({ ...prev, completed: Math.max(0, prev.completed - completedCount) })) toast.success(`${completedCount}개의 완료된 파일이 대기열에서 제거되었습니다`) } const resetUpload = () => { setSelectedFile(null) setUploadProgress(null) if (fileInputRef.current) { fileInputRef.current.value = '' } } const resetBatchUpload = () => { if (isBatchProcessing) { stopBatchProcessing() } setFileQueue([]) setBatchProgress({ total: 0, completed: 0, failed: 0 }) } const handleCancelClick = () => { if (isUploading && uploadProgress?.stage !== "complete") { toast.warning("처리 중에는 취소할 수 없습니다. 완료될 때까지 기다려주세요.", { description: "OCR 처리를 안전하게 중단할 수 없습니다." }) } else { setIsUploadDialogOpen(false) resetUpload() } } // 현재 페이지 데이터만 내보내기 const exportCurrentPage = () => { exportTableToExcel(table, { filename: "OCR 결과 (현재 페이지)", excludeColumns: ["select", "actions"], }) } // 전체 데이터 내보내기 const exportAllData = async () => { if (isExporting) return setIsExporting(true) try { toast.loading("전체 데이터를 가져오는 중...", { description: "잠시만 기다려주세요." }) const allData = await getOcrAllRows() toast.dismiss() if (allData.length === 0) { toast.warning("내보낼 데이터가 없습니다.") return } await exportOcrDataToExcel(allData, `OCR 결과 (전체 데이터 - ${allData.length}개 행)`) toast.success(`전체 데이터 ${allData.length}개 행이 성공적으로 내보내졌습니다.`) } catch (error) { console.error('전체 데이터 내보내기 오류:', error) toast.error('전체 데이터 내보내기 중 오류가 발생했습니다.') } finally { setIsExporting(false) } } // 삭제 후 콜백 - 테이블 새로고침 const handleDeleteSuccess = () => { // 선택 해제 table.resetRowSelection() // 페이지 새로고침 window.location.reload() } const getStatusBadgeVariant = (status: FileUploadItem['status']) => { switch (status) { case 'pending': return 'secondary' case 'processing': return 'default' case 'completed': return 'default' case 'failed': return 'destructive' default: return 'secondary' } } const getStatusIcon = (status: FileUploadItem['status']) => { switch (status) { case 'pending': return case 'processing': return case 'completed': return case 'failed': return default: return } } const getStatusText = (status: FileUploadItem['status']) => { switch (status) { case 'pending': return '대기 중' case 'processing': return '처리 중' case 'completed': return '완료' case 'failed': return '실패' default: return '대기 중' } } return (
{/* 선택된 행이 있을 때만 삭제 버튼 표시 */} {selectedRows.length > 0 && ( )} {/* 단일 파일 OCR 업로드 다이얼로그 */} { if (isUploading && uploadProgress?.stage !== "complete") { e.preventDefault() toast.warning("처리 중에는 창을 닫을 수 없습니다. 완료될 때까지 기다려주세요.") } }} onInteractOutside={(e) => { if (isUploading && uploadProgress?.stage !== "complete") { e.preventDefault() toast.warning("처리 중에는 창을 닫을 수 없습니다. 완료될 때까지 기다려주세요.") } }} > OCR용 문서 업로드 {isUploading && uploadProgress?.stage !== "complete" && ( )} {isUploading && uploadProgress?.stage !== "complete" ? "처리가 진행 중입니다. 이 창을 닫지 마세요." : "OCR 기술을 사용하여 테이블 데이터를 추출할 PDF 또는 이미지 파일을 업로드하세요." }

지원 형식: PDF, JPG, PNG, TIFF, BMP (최대 10MB)

{selectedFile && (
{selectedFile.name}
크기: {(selectedFile.size / 1024 / 1024).toFixed(2)} MB 형식: {selectedFile.type}
)} {uploadProgress && (
처리 중... {uploadProgress.stage === "preparing" && "준비 중"} {uploadProgress.stage === "uploading" && "업로드 중"} {uploadProgress.stage === "processing" && "처리 중"} {uploadProgress.stage === "saving" && "저장 중"} {uploadProgress.stage === "complete" && "완료"}

{uploadProgress.message}

{isUploading && uploadProgress.stage !== "complete" && (

잠시만 기다려주세요... 완료되면 이 창이 자동으로 닫힙니다.

)}
)}
{/* 배치 파일 OCR 업로드 다이얼로그 */} { if (isBatchProcessing && !isPaused) { e.preventDefault() toast.warning("일괄 처리 중에는 창을 닫을 수 없습니다. 먼저 일시정지하세요.") } }} onInteractOutside={(e) => { if (isBatchProcessing && !isPaused) { e.preventDefault() toast.warning("일괄 처리 중에는 창을 닫을 수 없습니다. 먼저 일시정지하세요.") } }} > 일괄 OCR 업로드 {isBatchProcessing && ( )}
전체: {batchProgress.total} 완료: {batchProgress.completed} {batchProgress.failed > 0 && ( 실패: {batchProgress.failed} )}
{isBatchProcessing && !isPaused ? `파일 처리 중... 현재: ${batchProgress.current || '시작 중...'}` : "여러 파일을 드래그 앤 드롭하거나 선택하여 일괄 처리하세요." }
{/* 파일 드롭존 */} {fileQueue.length === 0 && ( 파일을 여기로 드래그하거나 클릭하여 선택 PDF, JPG, PNG, TIFF, BMP 파일 지원 (각각 최대 10MB) e.target.files && handleFilesSelect(e.target.files)} /> )} {/* 배치 진행 상황 */} {batchProgress.total > 0 && (
진행률: {batchProgress.completed + batchProgress.failed} / {batchProgress.total} {Math.round(((batchProgress.completed + batchProgress.failed) / batchProgress.total) * 100)}%
{batchProgress.current && (

현재 처리 중: {batchProgress.current}

)}
)} {/* 파일 목록 */} {fileQueue.length > 0 && (
파일 ({fileQueue.length}개)
{fileQueue.some(item => item.status === 'failed') && ( )} {fileQueue.some(item => item.status === 'completed') && ( )}
{fileQueue.map((fileItem) => ( {getStatusIcon(fileItem.status)} {fileItem.file.name} {fileItem.file.size} {fileItem.result && ( {fileItem.result.totalTables}개 테이블, {fileItem.result.totalRows}개 행 )} {fileItem.error && ( 오류: {fileItem.error} )}
{getStatusText(fileItem.status)} {fileItem.status !== 'processing' && ( removeFileFromQueue(fileItem.id)} disabled={isBatchProcessing && fileItem.status === 'processing'} > )}
))}
)} {/* 액션 버튼들 */}
{isBatchProcessing && ( <> )}
{/* Export 드롭다운 메뉴 */} 전체 데이터 내보내기 현재 페이지 내보내기
) }