summaryrefslogtreecommitdiff
path: root/lib/welding
diff options
context:
space:
mode:
Diffstat (limited to 'lib/welding')
-rw-r--r--lib/welding/table/ocr-table-toolbar-actions.tsx667
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>