summaryrefslogtreecommitdiff
path: root/lib/vendor-document-list/plant/upload/components
diff options
context:
space:
mode:
Diffstat (limited to 'lib/vendor-document-list/plant/upload/components')
-rw-r--r--lib/vendor-document-list/plant/upload/components/history-dialog.tsx144
-rw-r--r--lib/vendor-document-list/plant/upload/components/multi-upload-dialog.tsx492
-rw-r--r--lib/vendor-document-list/plant/upload/components/project-filter.tsx109
-rw-r--r--lib/vendor-document-list/plant/upload/components/single-upload-dialog.tsx265
-rw-r--r--lib/vendor-document-list/plant/upload/components/view-submission-dialog.tsx520
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