diff options
Diffstat (limited to 'lib/vendor-document-list/ship/bulk-b4-upload-dialog.tsx')
| -rw-r--r-- | lib/vendor-document-list/ship/bulk-b4-upload-dialog.tsx | 428 |
1 files changed, 428 insertions, 0 deletions
diff --git a/lib/vendor-document-list/ship/bulk-b4-upload-dialog.tsx b/lib/vendor-document-list/ship/bulk-b4-upload-dialog.tsx new file mode 100644 index 00000000..65166bd6 --- /dev/null +++ b/lib/vendor-document-list/ship/bulk-b4-upload-dialog.tsx @@ -0,0 +1,428 @@ +"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 { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog" +import { Button } from "@/components/ui/button" +import { + Form, + FormControl, + FormField, + FormItem, + FormLabel, + FormMessage, +} from "@/components/ui/form" +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select" +import { ScrollArea } from "@/components/ui/scroll-area" +import { Separator } from "@/components/ui/separator" +import { Badge } from "@/components/ui/badge" +import { + Upload, + X, + Loader2, + FileSpreadsheet, + Files, + CheckCircle2, + AlertCircle, + AlertTriangle, + FileText, +} from "lucide-react" + +import { SimplifiedDocumentsView } from "@/db/schema" +import { bulkUploadB4Documents } from "../enhanced-document-service" + +// 파일명 파싱 유틸리티 +function parseFileName(fileName: string): { docNumber: string | null; revision: string | null } { + // 파일 확장자 제거 + const nameWithoutExt = fileName.replace(/\.[^.]+$/, "") + + // revision 패턴 찾기 (R01, r01, REV01, rev01 등) + const revisionMatch = nameWithoutExt.match(/[Rr](?:EV)?(\d+)/g) + const revision = revisionMatch ? revisionMatch[revisionMatch.length - 1].toUpperCase() : null + + // revision 제거한 나머지에서 docNumber 찾기 + let cleanedName = nameWithoutExt + if (revision) { + // revision과 그 앞의 구분자를 제거 + const revPattern = new RegExp(`[-_\\s]*${revision.replace(/([.*+?^=!:${}()|\[\]\/\\])/g, "\\$1")}.*$`, 'i') + cleanedName = cleanedName.replace(revPattern, "") + } + + // docNumber 패턴 찾기 (XX-XX-XX 형태) + // 공백이나 언더스코어를 하이픈으로 정규화 + const normalizedName = cleanedName.replace(/[\s_]+/g, '-') + + // 2~3자리 코드가 2~3개 연결된 패턴 찾기 + const docNumberPatterns = [ + /\b([A-Za-z]{2,3})-([A-Za-z]{2,3})-([A-Za-z0-9]{2,4})\b/, + /\b([A-Za-z]{2,3})\s+([A-Za-z]{2,3})\s+([A-Za-z0-9]{2,4})\b/, + ] + + let docNumber = null + for (const pattern of docNumberPatterns) { + const match = normalizedName.match(pattern) || cleanedName.match(pattern) + if (match) { + docNumber = `${match[1]}-${match[2]}-${match[3]}`.toUpperCase() + break + } + } + + return { docNumber, revision } +} + +// Form schema +const formSchema = z.object({ + projectId: z.string().min(1, "Please select a project"), + files: z.array(z.instanceof(File)).min(1, "Please select files"), +}) + +interface BulkB4UploadDialogProps { + open: boolean + onOpenChange: (open: boolean) => void + allDocuments: SimplifiedDocumentsView[] +} + +interface ParsedFile { + file: File + docNumber: string | null + revision: string | null + status: 'pending' | 'uploading' | 'success' | 'error' | 'ignored' + message?: string +} + +export function BulkB4UploadDialog({ + open, + onOpenChange, + allDocuments +}: BulkB4UploadDialogProps) { + const [isUploading, setIsUploading] = React.useState(false) + const [parsedFiles, setParsedFiles] = React.useState<ParsedFile[]>([]) + const router = useRouter() + + // 프로젝트 ID 추출 + const projectOptions = React.useMemo(() => { + const projectIds = [...new Set(allDocuments.map(doc => doc.projectId).filter(Boolean))] + return projectIds.map(id => ({ + id: String(id), + code: allDocuments.find(doc => doc.projectId === id)?.projectCode || `Project ${id}` + })) + }, [allDocuments]) + + const form = useForm<z.infer<typeof formSchema>>({ + resolver: zodResolver(formSchema), + defaultValues: { + projectId: "", + files: [], + }, + }) + + // 파일 선택 시 파싱 + const handleFilesChange = (files: File[]) => { + const parsed = files.map(file => { + const { docNumber, revision } = parseFileName(file.name) + return { + file, + docNumber, + revision, + status: docNumber ? 'pending' as const : 'ignored' as const, + message: !docNumber ? 'docNumber를 찾을 수 없음' : undefined + } + }) + + setParsedFiles(parsed) + form.setValue("files", files) + } + + // 파일 제거 + const removeFile = (index: number) => { + const newParsedFiles = parsedFiles.filter((_, i) => i !== index) + setParsedFiles(newParsedFiles) + form.setValue("files", newParsedFiles.map(pf => pf.file)) + } + + // 업로드 처리 + async function onSubmit(values: z.infer<typeof formSchema>) { + setIsUploading(true) + + try { + // 유효한 파일만 필터링 + const validFiles = parsedFiles.filter(pf => pf.docNumber && pf.status === 'pending') + + if (validFiles.length === 0) { + toast.error("업로드 가능한 파일이 없습니다") + return + } + + // 파일별로 상태 업데이트 + setParsedFiles(prev => prev.map(pf => + pf.docNumber && pf.status === 'pending' + ? { ...pf, status: 'uploading' as const } + : pf + )) + + // FormData 생성 + const formData = new FormData() + formData.append("projectId", values.projectId) + + validFiles.forEach((pf, index) => { + formData.append(`file_${index}`, pf.file) + formData.append(`docNumber_${index}`, pf.docNumber!) + formData.append(`revision_${index}`, pf.revision || "00") + }) + + formData.append("fileCount", String(validFiles.length)) + + // 서버 액션 호출 + const result = await bulkUploadB4Documents(formData) + + if (result.success) { + // 성공한 파일들 표시 + setParsedFiles(prev => prev.map(pf => { + const uploadResult = result.results?.find(r => + r.docNumber === pf.docNumber && r.revision === (pf.revision || "00") + ) + + if (uploadResult?.success) { + return { ...pf, status: 'success' as const, message: uploadResult.message } + } else if (uploadResult) { + return { ...pf, status: 'error' as const, message: uploadResult.error } + } + return pf + })) + + toast.success(`${result.successCount}/${validFiles.length} 파일 업로드 완료`) + + // 모두 성공하면 닫기 + if (result.successCount === validFiles.length) { + setTimeout(() => { + onOpenChange(false) + router.refresh() + }, 1500) + } + } else { + toast.error(result.error || "업로드 실패") + setParsedFiles(prev => prev.map(pf => + pf.status === 'uploading' + ? { ...pf, status: 'error' as const, message: result.error } + : pf + )) + } + } catch (error) { + toast.error("업로드 중 오류가 발생했습니다") + setParsedFiles(prev => prev.map(pf => + pf.status === 'uploading' + ? { ...pf, status: 'error' as const, message: '업로드 실패' } + : pf + )) + } finally { + setIsUploading(false) + } + } + + // 다이얼로그 닫을 때 초기화 + React.useEffect(() => { + if (!open) { + form.reset() + setParsedFiles([]) + } + }, [open, form]) + + const validFileCount = parsedFiles.filter(pf => pf.docNumber).length + const ignoredFileCount = parsedFiles.filter(pf => !pf.docNumber).length + + return ( + <Dialog open={open} onOpenChange={onOpenChange}> + <DialogContent className="max-w-3xl"> + <DialogHeader> + <DialogTitle>B4 Document Bulk Upload</DialogTitle> + <DialogDescription> + Document numbers and revisions will be automatically extracted from file names. + Example: "agadfg de na oc R01.pdf" → Document Number: DE-NA-OC, Revision: R01 + </DialogDescription> + </DialogHeader> + + <Form {...form}> + <form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4"> + <FormField + control={form.control} + name="projectId" + render={({ field }) => ( + <FormItem> + <FormLabel>Select Project *</FormLabel> + <Select onValueChange={field.onChange} value={field.value}> + <FormControl> + <SelectTrigger> + <SelectValue placeholder="Please select a project" /> + </SelectTrigger> + </FormControl> + <SelectContent> + {projectOptions.map(project => ( + <SelectItem key={project.id} value={project.id}> + {project.code} + </SelectItem> + ))} + </SelectContent> + </Select> + <FormMessage /> + </FormItem> + )} + /> + + <div className="space-y-2"> + <FormLabel>Select Files</FormLabel> + <div className="border-2 border-dashed rounded-lg p-6"> + <input + type="file" + multiple + accept=".pdf,.doc,.docx,.xls,.xlsx,.dwg,.dxf" + onChange={(e) => handleFilesChange(Array.from(e.target.files || []))} + className="hidden" + id="file-upload" + /> + <label + htmlFor="file-upload" + className="flex flex-col items-center justify-center cursor-pointer" + > + <Upload className="h-10 w-10 text-muted-foreground mb-2" /> + <p className="text-sm text-muted-foreground"> + Click or drag files to upload + </p> + <p className="text-xs text-muted-foreground mt-1"> + PDF, DOC, DOCX, XLS, XLSX, DWG, DXF + </p> + </label> + </div> + </div> + + {parsedFiles.length > 0 && ( + <div className="space-y-2"> + <div className="flex items-center justify-between"> + <FormLabel>Selected Files</FormLabel> + <div className="flex gap-2"> + <Badge variant="default"> + Valid: {validFileCount} + </Badge> + {ignoredFileCount > 0 && ( + <Badge variant="secondary"> + Ignored: {ignoredFileCount} + </Badge> + )} + </div> + </div> + + <ScrollArea className="h-[250px] border rounded-lg p-2"> + <div className="space-y-2"> + {parsedFiles.map((pf, index) => ( + <div + key={index} + className="flex items-center justify-between p-2 rounded-lg bg-muted/30" + > + <div className="flex items-center gap-3 flex-1"> + <FileText className="h-4 w-4 text-muted-foreground" /> + <div className="flex-1 min-w-0"> + <p className="text-sm font-medium truncate"> + {pf.file.name} + </p> + <div className="flex items-center gap-2 mt-1"> + {pf.docNumber ? ( + <> + <Badge variant="outline" className="text-xs"> + Doc: {pf.docNumber} + </Badge> + {pf.revision && ( + <Badge variant="outline" className="text-xs"> + Rev: {pf.revision} + </Badge> + )} + </> + ) : ( + <span className="text-xs text-muted-foreground"> + {pf.message} + </span> + )} + </div> + </div> + + <div className="flex items-center gap-2"> + {pf.status === 'uploading' && ( + <Loader2 className="h-4 w-4 animate-spin" /> + )} + {pf.status === 'success' && ( + <CheckCircle2 className="h-4 w-4 text-green-500" /> + )} + {pf.status === 'error' && ( + <AlertCircle className="h-4 w-4 text-red-500" /> + )} + {pf.status === 'ignored' && ( + <AlertTriangle className="h-4 w-4 text-yellow-500" /> + )} + + {!isUploading && ( + <Button + type="button" + variant="ghost" + size="sm" + onClick={() => removeFile(index)} + > + <X className="h-4 w-4" /> + </Button> + )} + </div> + </div> + </div> + ))} + </div> + </ScrollArea> + </div> + )} + + <DialogFooter> + <Button + type="button" + variant="outline" + onClick={() => onOpenChange(false)} + disabled={isUploading} + > + Cancel + </Button> + <Button + type="submit" + disabled={isUploading || validFileCount === 0 || !form.watch("projectId")} + > + {isUploading ? ( + <> + <Loader2 className="mr-2 h-4 w-4 animate-spin" /> + Uploading... + </> + ) : ( + <> + <Upload className="mr-2 h-4 w-4" /> + Upload ({validFileCount} files) + </> + )} + </Button> + </DialogFooter> + </form> + </Form> + </DialogContent> + </Dialog> + ) +}
\ No newline at end of file |
