diff options
Diffstat (limited to 'lib/vendor-document-list/plant/upload/components')
5 files changed, 1530 insertions, 0 deletions
diff --git a/lib/vendor-document-list/plant/upload/components/history-dialog.tsx b/lib/vendor-document-list/plant/upload/components/history-dialog.tsx new file mode 100644 index 00000000..9c4f160b --- /dev/null +++ b/lib/vendor-document-list/plant/upload/components/history-dialog.tsx @@ -0,0 +1,144 @@ +// lib/vendor-document-list/plant/upload/components/history-dialog.tsx +"use client" + +import * as React from "react" +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog" +import { Badge } from "@/components/ui/badge" +import { ScrollArea } from "@/components/ui/scroll-area" +import { + CheckCircle2, + XCircle, + Clock, + FileText, + User, + Calendar, + AlertCircle +} from "lucide-react" +import { StageSubmissionView } from "@/db/schema" +import { formatDateTime } from "@/lib/utils" + +interface HistoryDialogProps { + open: boolean + onOpenChange: (open: boolean) => void + submission: StageSubmissionView +} + +export function HistoryDialog({ + open, + onOpenChange, + submission +}: HistoryDialogProps) { + const history = submission.submissionHistory || [] + + const getStatusIcon = (status: string, reviewStatus?: string) => { + if (reviewStatus === "APPROVED") { + return <CheckCircle2 className="h-4 w-4 text-success" /> + } + if (reviewStatus === "REJECTED") { + return <XCircle className="h-4 w-4 text-destructive" /> + } + if (status === "SUBMITTED") { + return <Clock className="h-4 w-4 text-primary" /> + } + return <AlertCircle className="h-4 w-4 text-muted-foreground" /> + } + + const getStatusBadge = (status: string, reviewStatus?: string) => { + const variant = reviewStatus === "APPROVED" ? "success" : + reviewStatus === "REJECTED" ? "destructive" : + status === "SUBMITTED" ? "default" : "secondary" + + return ( + <Badge variant={variant}> + {reviewStatus || status} + </Badge> + ) + } + + return ( + <Dialog open={open} onOpenChange={onOpenChange}> + <DialogContent className="max-w-3xl max-h-[80vh]"> + <DialogHeader> + <DialogTitle>Submission History</DialogTitle> + <DialogDescription> + View all submission history for this stage + </DialogDescription> + </DialogHeader> + + {/* Document Info */} + <div className="grid gap-2 p-4 bg-muted rounded-lg"> + <div className="flex items-center justify-between"> + <div className="flex items-center gap-2"> + <FileText className="h-4 w-4 text-muted-foreground" /> + <span className="text-sm font-medium">{submission.docNumber}</span> + <span className="text-sm text-muted-foreground"> + - {submission.documentTitle} + </span> + </div> + <Badge variant="outline">{submission.stageName}</Badge> + </div> + </div> + + {/* History Timeline */} + <ScrollArea className="h-[400px] pr-4"> + {history.length === 0 ? ( + <div className="text-center py-8 text-muted-foreground"> + No submission history available + </div> + ) : ( + <div className="space-y-4"> + {history.map((item, index) => ( + <div key={item.submissionId} className="relative"> + {/* Timeline line */} + {index < history.length - 1 && ( + <div className="absolute left-5 top-10 bottom-0 w-0.5 bg-border" /> + )} + + {/* Timeline item */} + <div className="flex gap-4"> + <div className="flex-shrink-0 w-10 h-10 rounded-full bg-background border-2 border-border flex items-center justify-center"> + {getStatusIcon(item.status, item.reviewStatus)} + </div> + + <div className="flex-1 pb-4"> + <div className="flex items-center gap-2 mb-2"> + <span className="font-medium">Revision {item.revisionNumber}</span> + {getStatusBadge(item.status, item.reviewStatus)} + {item.syncStatus && ( + <Badge variant="outline" className="text-xs"> + Sync: {item.syncStatus} + </Badge> + )} + </div> + + <div className="grid gap-1 text-sm text-muted-foreground"> + <div className="flex items-center gap-2"> + <User className="h-3 w-3" /> + <span>{item.submittedBy}</span> + </div> + <div className="flex items-center gap-2"> + <Calendar className="h-3 w-3" /> + <span>{formatDateTime(new Date(item.submittedAt))}</span> + </div> + <div className="flex items-center gap-2"> + <FileText className="h-3 w-3" /> + <span>{item.fileCount} file(s)</span> + </div> + </div> + </div> + </div> + </div> + ))} + </div> + )} + </ScrollArea> + </DialogContent> + </Dialog> + ) +}
\ No newline at end of file diff --git a/lib/vendor-document-list/plant/upload/components/multi-upload-dialog.tsx b/lib/vendor-document-list/plant/upload/components/multi-upload-dialog.tsx new file mode 100644 index 00000000..81a1d486 --- /dev/null +++ b/lib/vendor-document-list/plant/upload/components/multi-upload-dialog.tsx @@ -0,0 +1,492 @@ +// lib/vendor-document-list/plant/upload/components/multi-upload-dialog.tsx +"use client" + +import * as React from "react" +import { useState, useCallback } from "react" +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, + DialogTrigger, +} from "@/components/ui/dialog" +import { Button } from "@/components/ui/button" +import { Badge } from "@/components/ui/badge" +import { Alert, AlertDescription } from "@/components/ui/alert" +import { ScrollArea } from "@/components/ui/scroll-area" +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 { + Upload, + X, + CheckCircle2, + AlertCircle, + Loader2, + CloudUpload, + FileWarning +} from "lucide-react" +import { toast } from "sonner" +import { validateFiles } from "../../document-stages-service" +import { parseFileName, ParsedFileName } from "../util/filie-parser" + +interface FileWithMetadata { + file: File + parsed: ParsedFileName + matched?: { + documentId: number + stageId: number + documentTitle: string + currentRevision?: string // number에서 string으로 변경 + } + status: 'pending' | 'validating' | 'uploading' | 'success' | 'error' + error?: string + progress?: number +} + +interface MultiUploadDialogProps { + projectId: number + // projectCode: string + onUploadComplete?: () => void +} + + +export function MultiUploadDialog({ + projectId, + // projectCode, + onUploadComplete +}: MultiUploadDialogProps) { + const [open, setOpen] = useState(false) + const [files, setFiles] = useState<FileWithMetadata[]>([]) + const [isValidating, setIsValidating] = useState(false) + const [isUploading, setIsUploading] = useState(false) + + // 디버깅용 로그 + console.log("Current files:", files) + + // 파일 추가 핸들러 - onChange 이벤트용 + const handleFilesChange = useCallback((e: React.ChangeEvent<HTMLInputElement>) => { + const fileList = e.target.files + console.log("Files selected via input:", fileList) + + if (fileList && fileList.length > 0) { + handleFilesAdded(Array.from(fileList)) + } + }, []) + + // 파일 추가 핸들러 - 공통 + const handleFilesAdded = useCallback(async (newFiles: File[]) => { + console.log("handleFilesAdded called with:", newFiles) + + if (!newFiles || newFiles.length === 0) { + console.log("No files provided") + return + } + + const processedFiles: FileWithMetadata[] = newFiles.map(file => { + const parsed = parseFileName(file.name) + console.log(`Parsed ${file.name}:`, parsed) + + return { + file, + parsed, + status: 'pending' as const + } + }) + + setFiles(prev => { + const updated = [...prev, ...processedFiles] + console.log("Updated files state:", updated) + return updated + }) + + // 유효한 파일들만 검증 + const validFiles = processedFiles.filter(f => f.parsed.isValid) + console.log("Valid files for validation:", validFiles) + + if (validFiles.length > 0) { + await validateFilesWithServer(validFiles) + } + }, []) + + // 서버 검증 + const validateFilesWithServer = async (filesToValidate: FileWithMetadata[]) => { + console.log("Starting validation for:", filesToValidate) + setIsValidating(true) + + setFiles(prev => prev.map(file => + filesToValidate.some(f => f.file === file.file) + ? { ...file, status: 'validating' as const } + : file + )) + + try { + const validationData = filesToValidate.map(f => ({ + projectId, // projectCode 대신 projectId 사용 + docNumber: f.parsed.docNumber, + stageName: f.parsed.stageName, + revision: f.parsed.revision + }))s + + console.log("Sending validation data:", validationData) + const results = await validateFiles(validationData) + console.log("Validation results:", results) + + // 매칭 결과 업데이트 - projectCode 체크 제거 + setFiles(prev => prev.map(file => { + const result = results.find(r => + r.docNumber === file.parsed.docNumber && + r.stageName === file.parsed.stageName + ) + + if (result && result.matched) { + console.log(`File ${file.file.name} matched:`, result.matched) + return { + ...file, + matched: result.matched, + status: 'pending' as const + } + } + return { ...file, status: 'pending' as const } + })) + } catch (error) { + console.error("Validation error:", error) + toast.error("Failed to validate files") + setFiles(prev => prev.map(file => + filesToValidate.some(f => f.file === file.file) + ? { ...file, status: 'error' as const, error: 'Validation failed' } + : file + )) + } finally { + setIsValidating(false) + } + } + // Drag and Drop 핸들러 + const handleDrop = useCallback((e: React.DragEvent<HTMLDivElement>) => { + e.preventDefault() + e.stopPropagation() + + const droppedFiles = Array.from(e.dataTransfer.files) + console.log("Files dropped:", droppedFiles) + + if (droppedFiles.length > 0) { + handleFilesAdded(droppedFiles) + } + }, [handleFilesAdded]) + + const handleDragOver = useCallback((e: React.DragEvent<HTMLDivElement>) => { + e.preventDefault() + e.stopPropagation() + }, []) + + // 파일 제거 + const removeFile = (index: number) => { + console.log("Removing file at index:", index) + setFiles(prev => prev.filter((_, i) => i !== index)) + } + + // 업로드 실행 + const handleUpload = async () => { + const uploadableFiles = files.filter(f => f.parsed.isValid && f.matched) + console.log("Files to upload:", uploadableFiles) + + if (uploadableFiles.length === 0) { + toast.error("No valid files to upload") + return + } + + setIsUploading(true) + + // 업로드 중 상태로 변경 + setFiles(prev => prev.map(file => + uploadableFiles.includes(file) + ? { ...file, status: 'uploading' as const } + : file + )) + + try { + const formData = new FormData() + + uploadableFiles.forEach((fileData, index) => { + formData.append(`files`, fileData.file) + formData.append(`metadata[${index}]`, JSON.stringify({ + documentId: fileData.matched!.documentId, + stageId: fileData.matched!.stageId, + revision: fileData.parsed.revision, + originalName: fileData.file.name + })) + }) + + console.log("Sending upload request") + const response = await fetch('/api/stage-submissions/bulk-upload', { + method: 'POST', + body: formData + }) + + if (!response.ok) { + const error = await response.text() + console.error("Upload failed:", error) + throw new Error('Upload failed') + } + + const result = await response.json() + console.log("Upload result:", result) + + // 성공 상태 업데이트 + setFiles(prev => prev.map(file => + uploadableFiles.includes(file) + ? { ...file, status: 'success' as const } + : file + )) + + toast.success(`Successfully uploaded ${result.uploaded} files`) + + setTimeout(() => { + setOpen(false) + setFiles([]) + onUploadComplete?.() + }, 2000) + + } catch (error) { + console.error("Upload error:", error) + toast.error("Upload failed") + + setFiles(prev => prev.map(file => + uploadableFiles.includes(file) + ? { ...file, status: 'error' as const, error: 'Upload failed' } + : file + )) + } finally { + setIsUploading(false) + } + } + + // 통계 계산 + const stats = { + total: files.length, + valid: files.filter(f => f.parsed.isValid).length, + matched: files.filter(f => f.matched).length, + ready: files.filter(f => f.parsed.isValid && f.matched).length, + totalSize: files.reduce((acc, f) => acc + f.file.size, 0) + } + + const formatFileSize = (bytes: number) => { + if (bytes === 0) return '0 Bytes' + const k = 1024 + const sizes = ['Bytes', 'KB', 'MB', 'GB'] + const i = Math.floor(Math.log(bytes) / Math.log(k)) + return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i] + } + + // 파일별 상태 아이콘 + const getStatusIcon = (fileData: FileWithMetadata) => { + if (!fileData.parsed.isValid) { + return <FileWarning className="h-4 w-4 text-destructive" /> + } + + switch (fileData.status) { + case 'validating': + return <Loader2 className="h-4 w-4 animate-spin text-muted-foreground" /> + case 'uploading': + return <Loader2 className="h-4 w-4 animate-spin text-primary" /> + case 'success': + return <CheckCircle2 className="h-4 w-4 text-success" /> + case 'error': + return <AlertCircle className="h-4 w-4 text-destructive" /> + default: + if (fileData.matched) { + return <CheckCircle2 className="h-4 w-4 text-success" /> + } else { + return <AlertCircle className="h-4 w-4 text-warning" /> + } + } + } + + // 파일별 상태 설명 + const getStatusDescription = (fileData: FileWithMetadata) => { + if (!fileData.parsed.isValid) { + return fileData.parsed.error || "Invalid format" + } + + switch (fileData.status) { + case 'validating': + return "Checking..." + case 'uploading': + return "Uploading..." + case 'success': + return "Uploaded" + case 'error': + return fileData.error || "Failed" + default: + if (fileData.matched) { + // projectCode 제거 + return `${fileData.parsed.docNumber}_${fileData.parsed.stageName}` + } else { + return "Document not found in system" + } + } + } + + return ( + <Dialog open={open} onOpenChange={setOpen}> + <DialogTrigger asChild> + <Button variant="outline" className="gap-2"> + <CloudUpload className="h-4 w-4" /> + Multi-Upload + </Button> + </DialogTrigger> + <DialogContent className="max-w-5xl max-h-[90vh] flex flex-col"> + <DialogHeader> + <DialogTitle>Bulk Document Upload</DialogTitle> + <DialogDescription> + Upload multiple files at once. Files should be named as: DocNumber_StageName_Revision.ext + </DialogDescription> + </DialogHeader> + + {/* Custom Dropzone with input */} + <div + className="border-2 border-dashed border-gray-300 rounded-lg p-8 text-center hover:border-gray-400 transition-colors cursor-pointer" + onDrop={handleDrop} + onDragOver={handleDragOver} + onClick={() => document.getElementById('file-upload')?.click()} + > + <input + id="file-upload" + type="file" + multiple + className="hidden" + onChange={handleFilesChange} + accept="*/*" + /> + <Upload className="mx-auto h-12 w-12 text-gray-400 mb-4" /> + <p className="text-lg font-medium">Drop files here or click to browse</p> + <p className="text-sm text-gray-500 mt-1"> + Maximum 10GB total • Format: DocNumber_StageName_Revision.ext + </p> + </div> + + {/* Stats */} + {files.length > 0 && ( + <div className="flex gap-2 flex-wrap"> + <Badge variant="outline">Total: {stats.total}</Badge> + <Badge variant={stats.valid === stats.total ? "success" : "secondary"}> + Valid Format: {stats.valid} + </Badge> + <Badge variant={stats.matched > 0 ? "success" : "secondary"}> + Matched: {stats.matched} + </Badge> + <Badge variant={stats.ready > 0 ? "default" : "outline"}> + Ready: {stats.ready} + </Badge> + <Badge variant="outline"> + Size: {formatFileSize(stats.totalSize)} + </Badge> + </div> + )} + + {/* File List */} + {files.length > 0 && ( + <div className="flex-1 rounded-md border overflow-y-auto" style={{ minHeight: 200, maxHeight: 400 }}> + <FileList className="p-4"> + <FileListHeader> + <div className="text-sm font-medium">Files ({files.length})</div> + </FileListHeader> + + {files.map((fileData, index) => ( + <FileListItem key={index}> + <FileListIcon> + {getStatusIcon(fileData)} + </FileListIcon> + + <FileListInfo> + <FileListName>{fileData.file.name}</FileListName> + <FileListDescription> + {getStatusDescription(fileData)} + </FileListDescription> + </FileListInfo> + + <FileListSize> + {fileData.file.size} + </FileListSize> + + <FileListAction> + <Button + variant="ghost" + size="icon" + className="h-8 w-8" + onClick={(e) => { + e.stopPropagation() + removeFile(index) + }} + disabled={isUploading || fileData.status === 'uploading'} + > + <X className="h-4 w-4" /> + </Button> + </FileListAction> + </FileListItem> + ))} + </FileList> + </div> + )} + {/* Error Alert */} + {files.filter(f => !f.parsed.isValid).length > 0 && ( + <Alert variant="destructive"> + <AlertCircle className="h-4 w-4" /> + <AlertDescription> + {files.filter(f => !f.parsed.isValid).length} file(s) have invalid naming format. + Expected: ProjectCode_DocNumber_StageName_Rev0.ext + </AlertDescription> + </Alert> + )} + + <DialogFooter> + <Button + variant="outline" + onClick={() => { + setOpen(false) + setFiles([]) + }} + disabled={isUploading} + > + Cancel + </Button> + <Button + onClick={handleUpload} + disabled={stats.ready === 0 || isUploading || isValidating} + className="gap-2" + > + {isUploading ? ( + <> + <Loader2 className="h-4 w-4 animate-spin" /> + Uploading {stats.ready} files... + </> + ) : ( + <> + <Upload className="h-4 w-4" /> + Upload {stats.ready} file{stats.ready !== 1 ? 's' : ''} + </> + )} + </Button> + </DialogFooter> + </DialogContent> + </Dialog> + ) +}
\ No newline at end of file diff --git a/lib/vendor-document-list/plant/upload/components/project-filter.tsx b/lib/vendor-document-list/plant/upload/components/project-filter.tsx new file mode 100644 index 00000000..33c2819b --- /dev/null +++ b/lib/vendor-document-list/plant/upload/components/project-filter.tsx @@ -0,0 +1,109 @@ +// lib/vendor-document-list/plant/upload/components/project-filter.tsx +"use client" + +import * as React from "react" +import { Check, ChevronsUpDown, Building2 } from "lucide-react" +import { cn } from "@/lib/utils" +import { Button } from "@/components/ui/button" +import { + Command, + CommandEmpty, + CommandGroup, + CommandInput, + CommandItem, + CommandList, +} from "@/components/ui/command" +import { + Popover, + PopoverContent, + PopoverTrigger, +} from "@/components/ui/popover" +import { Badge } from "@/components/ui/badge" + +interface Project { + id: number + code: string +} + +interface ProjectFilterProps { + projects: Project[] + value?: number | null + onValueChange: (value: number | null) => void +} + +export function ProjectFilter({ projects, value, onValueChange }: ProjectFilterProps) { + const [open, setOpen] = React.useState(false) + + const selectedProject = projects.find(p => p.id === value) + + return ( + <Popover open={open} onOpenChange={setOpen}> + <PopoverTrigger asChild> + <Button + variant="outline" + role="combobox" + aria-expanded={open} + className="w-[250px] justify-between" + > + <div className="flex items-center gap-2 truncate"> + <Building2 className="h-4 w-4 shrink-0 text-muted-foreground" /> + {selectedProject ? ( + <> + <span className="truncate">{selectedProject.code}</span> + <Badge variant="secondary" className="ml-1"> + Selected + </Badge> + </> + ) : ( + <span className="text-muted-foreground">All Projects</span> + )} + </div> + <ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" /> + </Button> + </PopoverTrigger> + <PopoverContent className="w-[250px] p-0"> + <Command> + <CommandInput placeholder="Search project..." /> + <CommandList> + <CommandEmpty>No project found.</CommandEmpty> + <CommandGroup> + <CommandItem + value="" + onSelect={() => { + onValueChange(null) + setOpen(false) + }} + > + <Check + className={cn( + "mr-2 h-4 w-4", + value === null ? "opacity-100" : "opacity-0" + )} + /> + <span className="text-muted-foreground">All Projects</span> + </CommandItem> + {projects.map((project) => ( + <CommandItem + key={project.id} + value={project.code} + onSelect={() => { + onValueChange(project.id) + setOpen(false) + }} + > + <Check + className={cn( + "mr-2 h-4 w-4", + value === project.id ? "opacity-100" : "opacity-0" + )} + /> + {project.code} + </CommandItem> + ))} + </CommandGroup> + </CommandList> + </Command> + </PopoverContent> + </Popover> + ) +}
\ No newline at end of file diff --git a/lib/vendor-document-list/plant/upload/components/single-upload-dialog.tsx b/lib/vendor-document-list/plant/upload/components/single-upload-dialog.tsx new file mode 100644 index 00000000..a33a7160 --- /dev/null +++ b/lib/vendor-document-list/plant/upload/components/single-upload-dialog.tsx @@ -0,0 +1,265 @@ +// lib/vendor-document-list/plant/upload/components/single-upload-dialog.tsx +"use client" + +import * as React from "react" +import { useState } from "react" +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog" +import { Button } from "@/components/ui/button" +import { Input } from "@/components/ui/input" +import { Label } from "@/components/ui/label" +import { Textarea } from "@/components/ui/textarea" +import { Badge } from "@/components/ui/badge" +import { Alert, AlertDescription } from "@/components/ui/alert" +import { + FileList, + FileListAction, + FileListIcon, + FileListInfo, + FileListItem, + FileListName, + FileListSize, +} from "@/components/ui/file-list" +import { + Upload, + X, + FileIcon, + Loader2, + AlertCircle +} from "lucide-react" +import { toast } from "sonner" +import { StageSubmissionView } from "@/db/schema" + +interface SingleUploadDialogProps { + open: boolean + onOpenChange: (open: boolean) => void + submission: StageSubmissionView + onUploadComplete?: () => void +} + +export function SingleUploadDialog({ + open, + onOpenChange, + submission, + onUploadComplete +}: SingleUploadDialogProps) { + const [files, setFiles] = useState<File[]>([]) + const [description, setDescription] = useState("") + const [isUploading, setIsUploading] = useState(false) + const fileInputRef = React.useRef<HTMLInputElement>(null) + + // 파일 선택 + const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => { + const fileList = e.target.files + if (fileList) { + setFiles(Array.from(fileList)) + } + } + + // 파일 제거 + const removeFile = (index: number) => { + setFiles(prev => prev.filter((_, i) => i !== index)) + } + + // 업로드 처리 + const handleUpload = async () => { + if (files.length === 0) { + toast.error("Please select files to upload") + return + } + + setIsUploading(true) + + try { + const formData = new FormData() + + files.forEach((file) => { + formData.append("files", file) + }) + + formData.append("documentId", submission.documentId.toString()) + formData.append("stageId", submission.stageId!.toString()) + formData.append("description", description) + + // 현재 리비전 + 1 + const nextRevision = (submission.latestRevisionNumber || 0) + 1 + formData.append("revision", nextRevision.toString()) + + const response = await fetch("/api/stage-submissions/upload", { + method: "POST", + body: formData, + }) + + if (!response.ok) { + throw new Error("Upload failed") + } + + const result = await response.json() + toast.success(`Successfully uploaded ${files.length} file(s)`) + + // 초기화 및 닫기 + setFiles([]) + setDescription("") + onOpenChange(false) + onUploadComplete?.() + + } catch (error) { + console.error("Upload error:", error) + toast.error("Failed to upload files") + } finally { + setIsUploading(false) + } + } + + const formatFileSize = (bytes: number) => { + if (bytes === 0) return '0 Bytes' + const k = 1024 + const sizes = ['Bytes', 'KB', 'MB', 'GB'] + const i = Math.floor(Math.log(bytes) / Math.log(k)) + return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i] + } + + const totalSize = files.reduce((acc, file) => acc + file.size, 0) + + return ( + <Dialog open={open} onOpenChange={onOpenChange}> + <DialogContent className="max-w-2xl"> + <DialogHeader> + <DialogTitle>Upload Documents</DialogTitle> + <DialogDescription> + Upload documents for this stage submission + </DialogDescription> + </DialogHeader> + + {/* Document Info */} + <div className="grid gap-2 p-4 bg-muted rounded-lg"> + <div className="flex items-center gap-2"> + <span className="text-sm font-medium">Document:</span> + <span className="text-sm">{submission.docNumber}</span> + {submission.vendorDocNumber && ( + <span className="text-sm text-muted-foreground"> + ({submission.vendorDocNumber}) + </span> + )} + </div> + <div className="flex items-center gap-2"> + <span className="text-sm font-medium">Stage:</span> + <Badge variant="secondary">{submission.stageName}</Badge> + </div> + <div className="flex items-center gap-2"> + <span className="text-sm font-medium">Current Revision:</span> + <span className="text-sm">Rev. {submission.latestRevisionNumber || 0}</span> + <Badge variant="outline" className="ml-2"> + Next: Rev. {(submission.latestRevisionNumber || 0) + 1} + </Badge> + </div> + </div> + + {/* File Upload Area */} + <div + className="border-2 border-dashed border-gray-300 rounded-lg p-6 text-center hover:border-gray-400 transition-colors cursor-pointer" + onClick={() => fileInputRef.current?.click()} + > + <input + ref={fileInputRef} + type="file" + multiple + className="hidden" + onChange={handleFileChange} + accept="*/*" + /> + <Upload className="mx-auto h-10 w-10 text-gray-400 mb-3" /> + <p className="text-sm font-medium">Click to browse files</p> + <p className="text-xs text-gray-500 mt-1"> + You can select multiple files + </p> + </div> + + {/* File List */} + {files.length > 0 && ( + <> + <FileList> + {files.map((file, index) => ( + <FileListItem key={index}> + <FileListIcon> + <FileIcon className="h-4 w-4 text-muted-foreground" /> + </FileListIcon> + <FileListInfo> + <FileListName>{file.name}</FileListName> + </FileListInfo> + <FileListSize> + {file.size} + </FileListSize> + <FileListAction> + <Button + variant="ghost" + size="icon" + className="h-8 w-8" + onClick={(e) => { + e.stopPropagation() + removeFile(index) + }} + disabled={isUploading} + > + <X className="h-4 w-4" /> + </Button> + </FileListAction> + </FileListItem> + ))} + </FileList> + + <div className="flex justify-between text-sm text-muted-foreground"> + <span>{files.length} file(s) selected</span> + <span>Total: {formatFileSize(totalSize)}</span> + </div> + </> + )} + + {/* Description */} + <div className="space-y-2"> + <Label htmlFor="description">Description (Optional)</Label> + <Textarea + id="description" + placeholder="Add a description for this submission..." + value={description} + onChange={(e) => setDescription(e.target.value)} + rows={3} + /> + </div> + + <DialogFooter> + <Button + variant="outline" + onClick={() => onOpenChange(false)} + disabled={isUploading} + > + Cancel + </Button> + <Button + onClick={handleUpload} + disabled={files.length === 0 || isUploading} + className="gap-2" + > + {isUploading ? ( + <> + <Loader2 className="h-4 w-4 animate-spin" /> + Uploading... + </> + ) : ( + <> + <Upload className="h-4 w-4" /> + Upload + </> + )} + </Button> + </DialogFooter> + </DialogContent> + </Dialog> + ) +}
\ No newline at end of file diff --git a/lib/vendor-document-list/plant/upload/components/view-submission-dialog.tsx b/lib/vendor-document-list/plant/upload/components/view-submission-dialog.tsx new file mode 100644 index 00000000..9a55a7fa --- /dev/null +++ b/lib/vendor-document-list/plant/upload/components/view-submission-dialog.tsx @@ -0,0 +1,520 @@ +// lib/vendor-document-list/plant/upload/components/view-submission-dialog.tsx +"use client" + +import * as React from "react" +import { useState, useEffect, useRef } from "react" +import { WebViewerInstance } from "@pdftron/webviewer" +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog" +import { Button } from "@/components/ui/button" +import { Badge } from "@/components/ui/badge" +import { ScrollArea } from "@/components/ui/scroll-area" +import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs" +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/components/ui/table" +import { + Download, + Eye, + FileText, + Calendar, + User, + CheckCircle2, + XCircle, + Clock, + RefreshCw, + Loader2 +} from "lucide-react" +import { StageSubmissionView } from "@/db/schema" +import { formatDateTime, formatDate } from "@/lib/utils" +import { toast } from "sonner" +import { downloadFile, formatFileSize } from "@/lib/file-download" + +interface ViewSubmissionDialogProps { + open: boolean + onOpenChange: (open: boolean) => void + submission: StageSubmissionView +} + +interface SubmissionDetail { + id: number + revisionNumber: number + submissionStatus: string + reviewStatus?: string + reviewComments?: string + submittedBy: string + submittedAt: Date + files: Array<{ + id: number + originalFileName: string + fileSize: number + uploadedAt: Date + syncStatus: string + storageUrl: string + }> +} + +// PDFTron 문서 뷰어 컴포넌트 +const DocumentViewer: React.FC<{ + open: boolean + onClose: () => void + files: Array<{ + id: number + originalFileName: string + storageUrl: string + }> +}> = ({ open, onClose, files }) => { + const [instance, setInstance] = useState<null | WebViewerInstance>(null) + const [viewerLoading, setViewerLoading] = useState<boolean>(true) + const [fileSetLoading, setFileSetLoading] = useState<boolean>(true) + const viewer = useRef<HTMLDivElement>(null) + const initialized = useRef(false) + const isCancelled = useRef(false) + + const cleanupHtmlStyle = () => { + const htmlElement = document.documentElement + const originalStyle = htmlElement.getAttribute("style") || "" + const colorSchemeStyle = originalStyle + .split(";") + .map((s) => s.trim()) + .find((s) => s.startsWith("color-scheme:")) + + if (colorSchemeStyle) { + htmlElement.setAttribute("style", colorSchemeStyle + ";") + } else { + htmlElement.removeAttribute("style") + } + } + + useEffect(() => { + if (open && !initialized.current) { + initialized.current = true + isCancelled.current = false + + requestAnimationFrame(() => { + if (viewer.current) { + import("@pdftron/webviewer").then(({ default: WebViewer }) => { + if (isCancelled.current) { + console.log("WebViewer 초기화 취소됨") + return + } + + WebViewer( + { + path: "/pdftronWeb", + licenseKey: + "demo:1739264618684:616161d7030000000091db1c97c6f386d41d3506ab5b507381ef2ee2bd", + fullAPI: true, + css: "/globals.css", + }, + viewer.current as HTMLDivElement + ).then(async (instance: WebViewerInstance) => { + setInstance(instance) + instance.UI.enableFeatures([instance.UI.Feature.MultiTab]) + instance.UI.disableElements([ + "addTabButton", + "multiTabsEmptyPage", + ]) + setViewerLoading(false) + }) + }) + } + }) + } + + return () => { + if (instance) { + instance.UI.dispose() + } + setTimeout(() => cleanupHtmlStyle(), 500) + } + }, [open]) + + useEffect(() => { + const loadDocuments = async () => { + if (instance && files.length > 0) { + const { UI } = instance + const tabIds = [] + + for (const file of files) { + const fileExtension = file.originalFileName.split('.').pop()?.toLowerCase() + + const options = { + filename: file.originalFileName, + ...(fileExtension === 'xlsx' || fileExtension === 'xls' ? { + officeOptions: { + formatOptions: { + applyPageBreaksToSheet: true, + }, + }, + } : {}), + } + + try { + const response = await fetch(file.storageUrl) + const blob = await response.blob() + const tab = await UI.TabManager.addTab(blob, options) + tabIds.push(tab) + } catch (error) { + console.error(`Failed to load ${file.originalFileName}:`, error) + toast.error(`Failed to load ${file.originalFileName}`) + } + } + + if (tabIds.length > 0) { + await UI.TabManager.setActiveTab(tabIds[0]) + } + + setFileSetLoading(false) + } + } + + loadDocuments() + }, [instance, files]) + + const handleClose = async () => { + if (!fileSetLoading) { + if (instance) { + try { + await instance.UI.dispose() + setInstance(null) + } catch (e) { + console.warn("dispose error", e) + } + } + + setTimeout(() => cleanupHtmlStyle(), 1000) + onClose() + } + } + + return ( + <Dialog open={open} onOpenChange={(val) => !val && handleClose()}> + <DialogContent className="w-[90vw] h-[90vh]" style={{ maxWidth: "none" }}> + <DialogHeader className="h-[38px]"> + <DialogTitle>Preview</DialogTitle> + {/* <DialogDescription>첨부파일 미리보기</DialogDescription> */} + </DialogHeader> + <div + ref={viewer} + style={{ height: "calc(90vh - 20px - 38px - 1rem - 48px)" }} + > + {viewerLoading && ( + <div className="flex flex-col items-center justify-center py-12"> + <Loader2 className="h-8 w-8 text-blue-500 animate-spin mb-4" /> + <p className="text-sm text-muted-foreground"> + 문서 뷰어 로딩 중... + </p> + </div> + )} + </div> + </DialogContent> + </Dialog> + ) +} + +export function ViewSubmissionDialog({ + open, + onOpenChange, + submission +}: ViewSubmissionDialogProps) { + const [loading, setLoading] = useState(false) + const [submissionDetail, setSubmissionDetail] = useState<SubmissionDetail | null>(null) + const [downloadingFiles, setDownloadingFiles] = useState<Set<number>>(new Set()) + const [viewerOpen, setViewerOpen] = useState(false) + const [selectedFiles, setSelectedFiles] = useState<Array<{ + id: number + originalFileName: string + storageUrl: string + }>>([]) + + useEffect(() => { + if (open && submission.latestSubmissionId) { + fetchSubmissionDetail() + } + }, [open, submission.latestSubmissionId]) + + const fetchSubmissionDetail = async () => { + if (!submission.latestSubmissionId) return + + setLoading(true) + try { + const response = await fetch(`/api/stage-submissions/${submission.latestSubmissionId}`) + if (response.ok) { + const data = await response.json() + setSubmissionDetail(data) + } + } catch (error) { + console.error("Failed to fetch submission details:", error) + toast.error("Failed to load submission details") + } finally { + setLoading(false) + } + } + + const handleDownload = async (file: any) => { + setDownloadingFiles(prev => new Set(prev).add(file.id)) + + try { + const result = await downloadFile( + file.storageUrl, + file.originalFileName, + { + action: 'download', + showToast: true, + showSuccessToast: true, + onError: (error) => { + console.error("Download failed:", error) + toast.error(`Failed to download ${file.originalFileName}`) + }, + onSuccess: (fileName, fileSize) => { + console.log(`Successfully downloaded ${fileName}`) + } + } + ) + + if (!result.success) { + console.error("Download failed:", result.error) + } + } finally { + setDownloadingFiles(prev => { + const newSet = new Set(prev) + newSet.delete(file.id) + return newSet + }) + } + } + + // PDFTron으로 미리보기 처리 + const handlePreview = (file: any) => { + setSelectedFiles([{ + id: file.id, + originalFileName: file.originalFileName, + storageUrl: file.storageUrl + }]) + setViewerOpen(true) + } + + // 모든 파일 미리보기 + const handlePreviewAll = () => { + if (submissionDetail) { + const files = submissionDetail.files.map(file => ({ + id: file.id, + originalFileName: file.originalFileName, + storageUrl: file.storageUrl + })) + setSelectedFiles(files) + setViewerOpen(true) + } + } + + const getStatusBadge = (status?: string) => { + if (!status) return null + + const variant = status === "APPROVED" ? "success" : + status === "REJECTED" ? "destructive" : + status === "SUBMITTED" ? "default" : "secondary" + + return <Badge variant={variant}>{status}</Badge> + } + + return ( + <> + <Dialog open={open} onOpenChange={onOpenChange}> + <DialogContent className="max-w-4xl max-h-[80vh]"> + <DialogHeader> + <DialogTitle>View Submission</DialogTitle> + <DialogDescription> + Submission details and attached files + </DialogDescription> + </DialogHeader> + + {loading ? ( + <div className="flex items-center justify-center py-8"> + <RefreshCw className="h-6 w-6 animate-spin text-muted-foreground" /> + </div> + ) : submissionDetail ? ( + <Tabs defaultValue="details" className="w-full"> + <TabsList className="grid w-full grid-cols-2"> + <TabsTrigger value="details">Details</TabsTrigger> + <TabsTrigger value="files"> + Files ({submissionDetail.files.length}) + </TabsTrigger> + </TabsList> + + <TabsContent value="details" className="space-y-4"> + <div className="grid gap-4"> + <div className="grid grid-cols-2 gap-4"> + <div className="space-y-1"> + <p className="text-sm font-medium text-muted-foreground"> + Revision + </p> + <p className="text-lg font-medium"> + Rev. {submissionDetail.revisionNumber} + </p> + </div> + <div className="space-y-1"> + <p className="text-sm font-medium text-muted-foreground"> + Status + </p> + <div className="flex items-center gap-2"> + {getStatusBadge(submissionDetail.submissionStatus)} + {submissionDetail.reviewStatus && + getStatusBadge(submissionDetail.reviewStatus)} + </div> + </div> + </div> + + <div className="grid grid-cols-2 gap-4"> + <div className="space-y-1"> + <p className="text-sm font-medium text-muted-foreground"> + Submitted By + </p> + <div className="flex items-center gap-2"> + <User className="h-4 w-4 text-muted-foreground" /> + <span>{submissionDetail.submittedBy}</span> + </div> + </div> + <div className="space-y-1"> + <p className="text-sm font-medium text-muted-foreground"> + Submitted At + </p> + <div className="flex items-center gap-2"> + <Calendar className="h-4 w-4 text-muted-foreground" /> + <span>{formatDateTime(submissionDetail.submittedAt)}</span> + </div> + </div> + </div> + + {submissionDetail.reviewComments && ( + <div className="space-y-1"> + <p className="text-sm font-medium text-muted-foreground"> + Review Comments + </p> + <div className="p-3 bg-muted rounded-lg"> + <p className="text-sm">{submissionDetail.reviewComments}</p> + </div> + </div> + )} + </div> + </TabsContent> + + <TabsContent value="files"> + <div className="flex justify-end mb-4"> + <Button + variant="outline" + size="sm" + onClick={handlePreviewAll} + disabled={submissionDetail.files.length === 0} + > + <Eye className="h-4 w-4 mr-2" /> + 모든 파일 미리보기 + </Button> + </div> + <ScrollArea className="h-[400px]"> + <Table> + <TableHeader> + <TableRow> + <TableHead>File Name</TableHead> + <TableHead>Size</TableHead> + <TableHead>Upload Date</TableHead> + <TableHead>Sync Status</TableHead> + <TableHead className="text-right">Actions</TableHead> + </TableRow> + </TableHeader> + <TableBody> + {submissionDetail.files.map((file) => { + const isDownloading = downloadingFiles.has(file.id) + + return ( + <TableRow key={file.id}> + <TableCell className="font-medium"> + <div className="flex items-center gap-2"> + <FileText className="h-4 w-4 text-muted-foreground" /> + {file.originalFileName} + </div> + </TableCell> + <TableCell>{formatFileSize(file.fileSize)}</TableCell> + <TableCell>{formatDate(file.uploadedAt)}</TableCell> + <TableCell> + <Badge + variant={ + file.syncStatus === "synced" ? "success" : + file.syncStatus === "failed" ? "destructive" : + "secondary" + } + className="text-xs" + > + {file.syncStatus} + </Badge> + </TableCell> + <TableCell className="text-right"> + <div className="flex justify-end gap-2"> + <Button + variant="ghost" + size="icon" + onClick={() => handleDownload(file)} + disabled={isDownloading} + title="Download" + > + {isDownloading ? ( + <RefreshCw className="h-4 w-4 animate-spin" /> + ) : ( + <Download className="h-4 w-4" /> + )} + </Button> + <Button + variant="ghost" + size="icon" + onClick={() => handlePreview(file)} + disabled={isDownloading} + title="Preview" + > + {isDownloading ? ( + <RefreshCw className="h-4 w-4 animate-spin" /> + ) : ( + <Eye className="h-4 w-4" /> + )} + </Button> + </div> + </TableCell> + </TableRow> + ) + })} + </TableBody> + </Table> + </ScrollArea> + </TabsContent> + </Tabs> + ) : ( + <div className="text-center py-8 text-muted-foreground"> + No submission found + </div> + )} + </DialogContent> + </Dialog> + + {/* PDFTron 문서 뷰어 다이얼로그 */} + {viewerOpen && ( + <DocumentViewer + open={viewerOpen} + onClose={() => { + setViewerOpen(false) + setSelectedFiles([]) + }} + files={selectedFiles} + /> + )} + </> + ) +}
\ No newline at end of file |
