diff options
Diffstat (limited to 'lib/welding/table')
| -rw-r--r-- | lib/welding/table/ocr-table-toolbar-actions.tsx | 667 |
1 files changed, 599 insertions, 68 deletions
diff --git a/lib/welding/table/ocr-table-toolbar-actions.tsx b/lib/welding/table/ocr-table-toolbar-actions.tsx index 120ff54f..03d8cab0 100644 --- a/lib/welding/table/ocr-table-toolbar-actions.tsx +++ b/lib/welding/table/ocr-table-toolbar-actions.tsx @@ -2,7 +2,7 @@ import * as React from "react" import { type Table } from "@tanstack/react-table" -import { Download, RefreshCcw, Upload, FileText, Loader2, ChevronDown } from "lucide-react" +import { Download, RefreshCcw, Upload, FileText, Loader2, ChevronDown, X, Play, Pause, RotateCcw } from "lucide-react" import { Button } from "@/components/ui/button" import { Input } from "@/components/ui/input" @@ -27,6 +27,25 @@ import { 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" @@ -40,6 +59,26 @@ interface UploadProgress { 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) @@ -49,25 +88,42 @@ export function OcrTableToolbarActions({ table }: OcrTableToolbarActionsProps) { const fileInputRef = React.useRef<HTMLInputElement>(null) const [isExporting, setIsExporting] = React.useState(false) - // 다이얼로그 닫기 핸들러 - 업로드 중에는 닫기 방지 + // 멀티 파일 업로드 관련 상태 + const [isBatchDialogOpen, setIsBatchDialogOpen] = React.useState(false) + const [fileQueue, setFileQueue] = React.useState<FileUploadItem[]>([]) + const [isBatchProcessing, setIsBatchProcessing] = React.useState(false) + const [batchProgress, setBatchProgress] = React.useState<BatchProgress>({ total: 0, completed: 0, failed: 0 }) + const [isPaused, setIsPaused] = React.useState(false) + const batchControllerRef = React.useRef<AbortController | null>(null) + + // 단일 파일 업로드 다이얼로그 닫기 핸들러 const handleDialogOpenChange = (open: boolean) => { - // 다이얼로그를 닫으려고 할 때 if (!open) { - // 업로드가 진행 중이면 닫기를 방지 if (isUploading && uploadProgress?.stage !== "complete") { - toast.warning("Cannot close while processing. Please wait for completion.", { - description: "OCR processing is in progress..." + toast.warning("처리 중에는 창을 닫을 수 없습니다. 완료될 때까지 기다려주세요.", { + description: "OCR 처리가 진행 중입니다..." }) - return // 다이얼로그를 닫지 않음 + 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<HTMLInputElement>) => { const file = event.target.files?.[0] if (file) { @@ -75,10 +131,36 @@ export function OcrTableToolbarActions({ table }: OcrTableToolbarActionsProps) { } } + // 멀티 파일 선택/드롭 핸들러 + 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 "File size must be less than 10MB" + return "파일 크기는 10MB 미만이어야 합니다" } // 파일 타입 체크 @@ -92,15 +174,16 @@ export function OcrTableToolbarActions({ table }: OcrTableToolbarActionsProps) { ] if (!allowedTypes.includes(file.type)) { - return "Only PDF and image files (JPG, PNG, TIFF, BMP) are supported" + return "PDF 및 이미지 파일(JPG, PNG, TIFF, BMP)만 지원됩니다" } return null } + // 단일 파일 업로드 const uploadFile = async () => { if (!selectedFile) { - toast.error("Please select a file first") + toast.error("먼저 파일을 선택하세요") return } @@ -115,7 +198,7 @@ export function OcrTableToolbarActions({ table }: OcrTableToolbarActionsProps) { setUploadProgress({ stage: "preparing", progress: 10, - message: "Preparing file upload..." + message: "파일 업로드 준비 중..." }) const formData = new FormData() @@ -124,7 +207,7 @@ export function OcrTableToolbarActions({ table }: OcrTableToolbarActionsProps) { setUploadProgress({ stage: "uploading", progress: 30, - message: "Uploading file and processing..." + message: "파일 업로드 및 처리 중..." }) const response = await fetch('/api/ocr/enhanced', { @@ -135,12 +218,12 @@ export function OcrTableToolbarActions({ table }: OcrTableToolbarActionsProps) { setUploadProgress({ stage: "processing", progress: 70, - message: "Analyzing document with OCR..." + message: "OCR을 사용하여 문서 분석 중..." }) if (!response.ok) { const errorData = await response.json() - throw new Error(errorData.error || 'OCR processing failed') + throw new Error(errorData.error || 'OCR 처리가 실패했습니다') } const result = await response.json() @@ -148,44 +231,41 @@ export function OcrTableToolbarActions({ table }: OcrTableToolbarActionsProps) { setUploadProgress({ stage: "saving", progress: 90, - message: "Saving results to database..." + message: "결과를 데이터베이스에 저장 중..." }) if (result.success) { setUploadProgress({ stage: "complete", progress: 100, - message: "OCR processing completed successfully!" + message: "OCR 처리가 성공적으로 완료되었습니다!" }) toast.success( - `OCR completed! Extracted ${result.metadata.totalRows} rows from ${result.metadata.totalTables} tables`, + `OCR 완료! ${result.metadata.totalTables}개 테이블에서 ${result.metadata.totalRows}개 행을 추출했습니다`, { description: result.warnings?.length - ? `Warnings: ${result.warnings.join(', ')}` + ? `경고: ${result.warnings.join(', ')}` : undefined } ) - // 성공 후 다이얼로그 닫기 및 상태 초기화 setTimeout(() => { setIsUploadDialogOpen(false) resetUpload() - - // 테이블 새로고침 window.location.reload() }, 2000) } else { - throw new Error(result.error || 'Unknown error occurred') + throw new Error(result.error || '알 수 없는 오류가 발생했습니다') } } catch (error) { - console.error('Error uploading file:', error) + console.error('파일 업로드 오류:', error) toast.error( error instanceof Error ? error.message - : 'An error occurred while processing the file' + : '파일 처리 중 오류가 발생했습니다' ) setUploadProgress(null) } finally { @@ -193,6 +273,190 @@ export function OcrTableToolbarActions({ table }: OcrTableToolbarActionsProps) { } } + // 배치 처리 시작 + 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) @@ -201,15 +465,20 @@ export function OcrTableToolbarActions({ table }: OcrTableToolbarActionsProps) { } } - // Cancel 버튼 핸들러 + const resetBatchUpload = () => { + if (isBatchProcessing) { + stopBatchProcessing() + } + setFileQueue([]) + setBatchProgress({ total: 0, completed: 0, failed: 0 }) + } + const handleCancelClick = () => { if (isUploading && uploadProgress?.stage !== "complete") { - // 업로드 진행 중이면 취소 불가능 메시지 - toast.warning("Cannot cancel while processing. Please wait for completion.", { - description: "OCR processing cannot be interrupted safely." + toast.warning("처리 중에는 취소할 수 없습니다. 완료될 때까지 기다려주세요.", { + description: "OCR 처리를 안전하게 중단할 수 없습니다." }) } else { - // 업로드 중이 아니거나 완료되었으면 다이얼로그 닫기 setIsUploadDialogOpen(false) resetUpload() } @@ -218,7 +487,7 @@ export function OcrTableToolbarActions({ table }: OcrTableToolbarActionsProps) { // 현재 페이지 데이터만 내보내기 const exportCurrentPage = () => { exportTableToExcel(table, { - filename: "OCR Result (Current Page)", + filename: "OCR 결과 (현재 페이지)", excludeColumns: ["select", "actions"], }) } @@ -234,9 +503,7 @@ export function OcrTableToolbarActions({ table }: OcrTableToolbarActionsProps) { description: "잠시만 기다려주세요." }) - // 모든 데이터 가져오기 const allData = await getOcrAllRows() - toast.dismiss() if (allData.length === 0) { @@ -244,68 +511,90 @@ export function OcrTableToolbarActions({ table }: OcrTableToolbarActionsProps) { return } - console.log(allData) - - // 새로운 단순한 export 함수 사용 - await exportOcrDataToExcel(allData, `OCR Result (All Data - ${allData.length} rows)`) - + await exportOcrDataToExcel(allData, `OCR 결과 (전체 데이터 - ${allData.length}개 행)`) toast.success(`전체 데이터 ${allData.length}개 행이 성공적으로 내보내졌습니다.`) } catch (error) { - console.error('Error exporting all data:', error) + console.error('전체 데이터 내보내기 오류:', error) toast.error('전체 데이터 내보내기 중 오류가 발생했습니다.') } finally { setIsExporting(false) } } + 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 <FileText className="size-4" /> + case 'processing': return <Loader2 className="size-4 animate-spin" /> + case 'completed': return <FileText className="size-4 text-green-600" /> + case 'failed': return <FileText className="size-4 text-red-600" /> + default: return <FileText className="size-4" /> + } + } + + const getStatusText = (status: FileUploadItem['status']) => { + switch (status) { + case 'pending': return '대기 중' + case 'processing': return '처리 중' + case 'completed': return '완료' + case 'failed': return '실패' + default: return '대기 중' + } + } + return ( <div className="flex items-center gap-2"> - {/* OCR 업로드 다이얼로그 */} + {/* 단일 파일 OCR 업로드 다이얼로그 */} <Dialog open={isUploadDialogOpen} onOpenChange={handleDialogOpenChange}> <DialogTrigger asChild> <Button variant="samsung" size="sm" className="gap-2"> <Upload className="size-4" aria-hidden="true" /> - <span className="hidden sm:inline">Upload OCR</span> + <span className="hidden sm:inline">OCR 업로드</span> </Button> </DialogTrigger> <DialogContent className="sm:max-w-md" - // 업로드 중에는 ESC 키로도 닫기 방지 onEscapeKeyDown={(e) => { if (isUploading && uploadProgress?.stage !== "complete") { e.preventDefault() - toast.warning("Cannot close while processing. Please wait for completion.") + toast.warning("처리 중에는 창을 닫을 수 없습니다. 완료될 때까지 기다려주세요.") } }} - // 업로드 중에는 외부 클릭으로도 닫기 방지 onInteractOutside={(e) => { if (isUploading && uploadProgress?.stage !== "complete") { e.preventDefault() - toast.warning("Cannot close while processing. Please wait for completion.") + toast.warning("처리 중에는 창을 닫을 수 없습니다. 완료될 때까지 기다려주세요.") } }} > <DialogHeader> <DialogTitle className="flex items-center gap-2"> - Upload Document for OCR - {/* 업로드 중일 때 로딩 인디케이터 표시 */} + OCR용 문서 업로드 {isUploading && uploadProgress?.stage !== "complete" && ( <Loader2 className="size-4 animate-spin text-muted-foreground" /> )} </DialogTitle> <DialogDescription> {isUploading && uploadProgress?.stage !== "complete" - ? "Processing in progress. Please do not close this dialog." - : "Upload a PDF or image file to extract table data using OCR technology." + ? "처리가 진행 중입니다. 이 창을 닫지 마세요." + : "OCR 기술을 사용하여 테이블 데이터를 추출할 PDF 또는 이미지 파일을 업로드하세요." } </DialogDescription> </DialogHeader> <div className="space-y-4"> - {/* 파일 선택 */} <div className="space-y-2"> - <Label htmlFor="file-upload">Select File</Label> + <Label htmlFor="file-upload">파일 선택</Label> <Input ref={fileInputRef} id="file-upload" @@ -315,11 +604,10 @@ export function OcrTableToolbarActions({ table }: OcrTableToolbarActionsProps) { disabled={isUploading} /> <p className="text-xs text-muted-foreground"> - Supported formats: PDF, JPG, PNG, TIFF, BMP (Max 10MB) + 지원 형식: PDF, JPG, PNG, TIFF, BMP (최대 10MB) </p> </div> - {/* 선택된 파일 정보 */} {selectedFile && ( <div className="rounded-lg border p-3 space-y-2"> <div className="flex items-center gap-2"> @@ -327,19 +615,22 @@ export function OcrTableToolbarActions({ table }: OcrTableToolbarActionsProps) { <span className="text-sm font-medium">{selectedFile.name}</span> </div> <div className="flex items-center gap-4 text-xs text-muted-foreground"> - <span>Size: {(selectedFile.size / 1024 / 1024).toFixed(2)} MB</span> - <span>Type: {selectedFile.type}</span> + <span>크기: {(selectedFile.size / 1024 / 1024).toFixed(2)} MB</span> + <span>형식: {selectedFile.type}</span> </div> </div> )} - {/* 업로드 진행상황 */} {uploadProgress && ( <div className="space-y-3"> <div className="flex items-center justify-between"> - <span className="text-sm font-medium">Processing...</span> + <span className="text-sm font-medium">처리 중...</span> <Badge variant={uploadProgress.stage === "complete" ? "default" : "secondary"}> - {uploadProgress.stage} + {uploadProgress.stage === "preparing" && "준비 중"} + {uploadProgress.stage === "uploading" && "업로드 중"} + {uploadProgress.stage === "processing" && "처리 중"} + {uploadProgress.stage === "saving" && "저장 중"} + {uploadProgress.stage === "complete" && "완료"} </Badge> </div> <Progress value={uploadProgress.progress} className="h-2" /> @@ -347,27 +638,25 @@ export function OcrTableToolbarActions({ table }: OcrTableToolbarActionsProps) { {uploadProgress.message} </p> - {/* 진행 중일 때 안내 메시지 */} {isUploading && uploadProgress.stage !== "complete" && ( <div className="flex items-center gap-2 p-2 bg-blue-50 dark:bg-blue-950/20 rounded-md"> <Loader2 className="size-3 animate-spin text-blue-600" /> <p className="text-xs text-blue-700 dark:text-blue-300"> - Please wait... This dialog will close automatically when complete. + 잠시만 기다려주세요... 완료되면 이 창이 자동으로 닫힙니다. </p> </div> )} </div> )} - {/* 액션 버튼들 */} <div className="flex justify-end gap-2"> <Button variant="outline" size="sm" onClick={handleCancelClick} - disabled={false} // 항상 클릭 가능하지만 핸들러에서 처리 + disabled={false} > - {isUploading && uploadProgress?.stage !== "complete" ? "Close" : "Cancel"} + {isUploading && uploadProgress?.stage !== "complete" ? "닫기" : "취소"} </Button> <Button size="sm" @@ -380,14 +669,256 @@ export function OcrTableToolbarActions({ table }: OcrTableToolbarActionsProps) { ) : ( <Upload className="size-4" aria-hidden="true" /> )} - {isUploading ? "Processing..." : "Start OCR"} + {isUploading ? "처리 중..." : "OCR 시작"} </Button> </div> </div> </DialogContent> </Dialog> - {/* Export 버튼 */} + {/* 배치 파일 OCR 업로드 다이얼로그 */} + <Dialog open={isBatchDialogOpen} onOpenChange={handleBatchDialogOpenChange}> + <DialogTrigger asChild> + <Button variant="outline" size="sm" className="gap-2"> + <Upload className="size-4" aria-hidden="true" /> + <span className="hidden sm:inline">일괄 업로드</span> + </Button> + </DialogTrigger> + <DialogContent + className="sm:max-w-2xl max-h-[80vh] overflow-hidden flex flex-col" + onEscapeKeyDown={(e) => { + if (isBatchProcessing && !isPaused) { + e.preventDefault() + toast.warning("일괄 처리 중에는 창을 닫을 수 없습니다. 먼저 일시정지하세요.") + } + }} + onInteractOutside={(e) => { + if (isBatchProcessing && !isPaused) { + e.preventDefault() + toast.warning("일괄 처리 중에는 창을 닫을 수 없습니다. 먼저 일시정지하세요.") + } + }} + > + <DialogHeader> + <DialogTitle className="flex items-center justify-between"> + <span className="flex items-center gap-2"> + 일괄 OCR 업로드 + {isBatchProcessing && ( + <Loader2 className="size-4 animate-spin text-muted-foreground" /> + )} + </span> + <div className="flex items-center gap-2 text-sm text-muted-foreground"> + <span>전체: {batchProgress.total}</span> + <span>완료: {batchProgress.completed}</span> + {batchProgress.failed > 0 && ( + <span className="text-red-600">실패: {batchProgress.failed}</span> + )} + </div> + </DialogTitle> + <DialogDescription> + {isBatchProcessing && !isPaused + ? `파일 처리 중... 현재: ${batchProgress.current || '시작 중...'}` + : "여러 파일을 드래그 앤 드롭하거나 선택하여 일괄 처리하세요." + } + </DialogDescription> + </DialogHeader> + + <div className="flex-1 overflow-hidden flex flex-col space-y-4"> + {/* 파일 드롭존 */} + {fileQueue.length === 0 && ( + <Dropzone onDrop={handleFilesSelect} className="border-2 border-dashed border-gray-300 rounded-lg"> + <DropzoneZone> + <DropzoneUploadIcon /> + <DropzoneTitle>파일을 여기로 드래그하거나 클릭하여 선택</DropzoneTitle> + <DropzoneDescription> + PDF, JPG, PNG, TIFF, BMP 파일 지원 (각각 최대 10MB) + </DropzoneDescription> + <DropzoneInput + multiple + accept=".pdf,.jpg,.jpeg,.png,.tiff,.bmp" + onChange={(e) => e.target.files && handleFilesSelect(e.target.files)} + /> + </DropzoneZone> + </Dropzone> + )} + + {/* 배치 진행 상황 */} + {batchProgress.total > 0 && ( + <div className="space-y-2"> + <div className="flex items-center justify-between text-sm"> + <span>진행률: {batchProgress.completed + batchProgress.failed} / {batchProgress.total}</span> + <span>{Math.round(((batchProgress.completed + batchProgress.failed) / batchProgress.total) * 100)}%</span> + </div> + <Progress + value={((batchProgress.completed + batchProgress.failed) / batchProgress.total) * 100} + className="h-2" + /> + {batchProgress.current && ( + <p className="text-xs text-muted-foreground"> + 현재 처리 중: {batchProgress.current} + </p> + )} + </div> + )} + + {/* 파일 목록 */} + {fileQueue.length > 0 && ( + <div className="flex-1 overflow-y-auto"> + <FileList className="h-full overflow-y-auto"> + <FileListHeader> + <div className="flex items-center justify-between"> + <span>파일 ({fileQueue.length}개)</span> + <div className="flex items-center gap-1"> + <Button + variant="ghost" + size="sm" + onClick={() => { + const input = document.createElement('input') + input.type = 'file' + input.multiple = true + input.accept = '.pdf,.jpg,.jpeg,.png,.tiff,.bmp' + input.onchange = (e) => { + const files = (e.target as HTMLInputElement).files + if (files) handleFilesSelect(files) + } + input.click() + }} + className="gap-1" + > + <Upload className="size-3" /> + 추가 + </Button> + {fileQueue.some(item => item.status === 'failed') && ( + <Button + variant="ghost" + size="sm" + onClick={retryFailedFiles} + className="gap-1" + > + <RotateCcw className="size-3" /> + 실패 재시도 + </Button> + )} + {fileQueue.some(item => item.status === 'completed') && ( + <Button + variant="ghost" + size="sm" + onClick={clearCompletedFiles} + className="gap-1" + > + <X className="size-3" /> + 완료 제거 + </Button> + )} + </div> + </div> + </FileListHeader> + + {fileQueue.map((fileItem) => ( + <FileListItem key={fileItem.id} className="flex items-center justify-between gap-3"> + <FileListIcon> + {getStatusIcon(fileItem.status)} + </FileListIcon> + <FileListInfo> + <FileListName>{fileItem.file.name}</FileListName> + <FileListDescription> + <FileListSize> + {fileItem.file.size} + </FileListSize> + {fileItem.result && ( + <span className="ml-2 text-green-600"> + {fileItem.result.totalTables}개 테이블, {fileItem.result.totalRows}개 행 + </span> + )} + {fileItem.error && ( + <span className="ml-2 text-red-600 text-xs"> + 오류: {fileItem.error} + </span> + )} + </FileListDescription> + </FileListInfo> + <div className="flex items-center gap-2"> + <Badge variant={getStatusBadgeVariant(fileItem.status)}> + {getStatusText(fileItem.status)} + </Badge> + {fileItem.status !== 'processing' && ( + <FileListAction + onClick={() => removeFileFromQueue(fileItem.id)} + disabled={isBatchProcessing && fileItem.status === 'processing'} + > + <X className="size-4" /> + </FileListAction> + )} + </div> + </FileListItem> + ))} + </FileList> + </div> + )} + + {/* 액션 버튼들 */} + <div className="flex justify-between"> + <Button + variant="outline" + size="sm" + onClick={() => { + if (isBatchProcessing && !isPaused) { + toast.warning("먼저 처리를 일시정지하세요") + } else { + setIsBatchDialogOpen(false) + resetBatchUpload() + } + }} + > + {isBatchProcessing && !isPaused ? "닫기" : "취소"} + </Button> + + <div className="flex items-center gap-2"> + {isBatchProcessing && ( + <> + <Button + variant="outline" + size="sm" + onClick={toggleBatchPause} + className="gap-2" + > + {isPaused ? ( + <Play className="size-4" /> + ) : ( + <Pause className="size-4" /> + )} + {isPaused ? "재개" : "일시정지"} + </Button> + <Button + variant="outline" + size="sm" + onClick={stopBatchProcessing} + className="gap-2" + > + <X className="size-4" /> + 중단 + </Button> + </> + )} + <Button + size="sm" + onClick={startBatchProcessing} + disabled={fileQueue.filter(item => item.status === 'pending').length === 0 || isBatchProcessing} + className="gap-2" + > + {isBatchProcessing ? ( + <Loader2 className="size-4 animate-spin" /> + ) : ( + <Play className="size-4" /> + )} + {isBatchProcessing ? "처리 중..." : "일괄 시작"} + </Button> + </div> + </div> + </div> + </DialogContent> + </Dialog> + {/* Export 드롭다운 메뉴 */} <DropdownMenu> <DropdownMenuTrigger asChild> @@ -403,7 +934,7 @@ export function OcrTableToolbarActions({ table }: OcrTableToolbarActionsProps) { <Download className="size-4" aria-hidden="true" /> )} <span className="hidden sm:inline"> - {isExporting ? "Exporting..." : "Export"} + {isExporting ? "내보내는 중..." : "내보내기"} </span> <ChevronDown className="size-3" aria-hidden="true" /> </Button> |
