summaryrefslogtreecommitdiff
path: root/lib/vendor-document-list/plant/upload
diff options
context:
space:
mode:
Diffstat (limited to 'lib/vendor-document-list/plant/upload')
-rw-r--r--lib/vendor-document-list/plant/upload/columns.tsx379
-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
-rw-r--r--lib/vendor-document-list/plant/upload/service.ts228
-rw-r--r--lib/vendor-document-list/plant/upload/table.tsx223
-rw-r--r--lib/vendor-document-list/plant/upload/toolbar-actions.tsx242
-rw-r--r--lib/vendor-document-list/plant/upload/util/filie-parser.ts132
-rw-r--r--lib/vendor-document-list/plant/upload/validation.ts35
11 files changed, 2769 insertions, 0 deletions
diff --git a/lib/vendor-document-list/plant/upload/columns.tsx b/lib/vendor-document-list/plant/upload/columns.tsx
new file mode 100644
index 00000000..c0f17afc
--- /dev/null
+++ b/lib/vendor-document-list/plant/upload/columns.tsx
@@ -0,0 +1,379 @@
+"use client"
+
+import * as React from "react"
+import { ColumnDef } from "@tanstack/react-table"
+import { formatDate, formatDateTime } from "@/lib/utils"
+import { Checkbox } from "@/components/ui/checkbox"
+import { DataTableColumnHeaderSimple } from "@/components/data-table/data-table-column-simple-header"
+import { DataTableRowAction } from "@/types/table"
+import { StageSubmissionView } from "@/db/schema"
+import { Button } from "@/components/ui/button"
+import { Badge } from "@/components/ui/badge"
+import { Progress } from "@/components/ui/progress"
+import {
+ DropdownMenu,
+ DropdownMenuContent,
+ DropdownMenuItem,
+ DropdownMenuSeparator,
+ DropdownMenuTrigger,
+} from "@/components/ui/dropdown-menu"
+import {
+ Ellipsis,
+ Upload,
+ Eye,
+ RefreshCw,
+ CheckCircle2,
+ XCircle,
+ AlertCircle,
+ Clock
+} from "lucide-react"
+
+interface GetColumnsProps {
+ setRowAction: React.Dispatch<React.SetStateAction<DataTableRowAction<StageSubmissionView> | null>>
+}
+
+export function getColumns({
+ setRowAction,
+}: GetColumnsProps): ColumnDef<StageSubmissionView>[] {
+ return [
+ {
+ id: "select",
+ header: ({ table }) => (
+ <Checkbox
+ checked={
+ table.getIsAllPageRowsSelected() ||
+ (table.getIsSomePageRowsSelected() && "indeterminate")
+ }
+ onCheckedChange={(value) => table.toggleAllPageRowsSelected(!!value)}
+ aria-label="Select all"
+ />
+ ),
+ cell: ({ row }) => (
+ <Checkbox
+ checked={row.getIsSelected()}
+ onCheckedChange={(value) => row.toggleSelected(!!value)}
+ aria-label="Select row"
+ />
+ ),
+ size: 40,
+ enableSorting: false,
+ enableHiding: false,
+ },
+ {
+ accessorKey: "docNumber",
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="Doc Number" />
+ ),
+ cell: ({ row }) => {
+ const vendorDocNumber = row.original.vendorDocNumber
+ return (
+ <div className="space-y-1">
+ <div className="font-medium">{row.getValue("docNumber")}</div>
+ {vendorDocNumber && (
+ <div className="text-xs text-muted-foreground">{vendorDocNumber}</div>
+ )}
+ </div>
+ )
+ },
+ size: 150,
+ },
+ {
+ accessorKey: "documentTitle",
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="Document Title" />
+ ),
+ cell: ({ row }) => (
+ <div className="max-w-[300px] truncate" title={row.getValue("documentTitle")}>
+ {row.getValue("documentTitle")}
+ </div>
+ ),
+ size: 250,
+ },
+ {
+ accessorKey: "projectCode",
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="Project" />
+ ),
+ cell: ({ row }) => (
+ <Badge variant="outline">{row.getValue("projectCode")}</Badge>
+ ),
+ size: 100,
+ },
+ {
+ accessorKey: "stageName",
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="Stage" />
+ ),
+ cell: ({ row }) => {
+ const stageName = row.getValue("stageName") as string
+ const stageStatus = row.original.stageStatus
+ const stageOrder = row.original.stageOrder
+
+ return (
+ <div className="space-y-1">
+ <div className="flex items-center gap-2">
+ <Badge variant="secondary" className="text-xs">
+ {stageOrder ? `#${stageOrder}` : ""}
+ </Badge>
+ <span className="text-sm">{stageName}</span>
+ </div>
+ {stageStatus && (
+ <Badge
+ variant={
+ stageStatus === "COMPLETED" ? "success" :
+ stageStatus === "IN_PROGRESS" ? "default" :
+ stageStatus === "REJECTED" ? "destructive" :
+ "secondary"
+ }
+ className="text-xs"
+ >
+ {stageStatus}
+ </Badge>
+ )}
+ </div>
+ )
+ },
+ size: 200,
+ },
+ {
+ accessorKey: "stagePlanDate",
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="Due Date" />
+ ),
+ cell: ({ row }) => {
+ const planDate = row.getValue("stagePlanDate") as Date | null
+ const isOverdue = row.original.isOverdue
+ const daysUntilDue = row.original.daysUntilDue
+
+ if (!planDate) return <span className="text-muted-foreground">-</span>
+
+ return (
+ <div className="space-y-1">
+ <div className={isOverdue ? "text-destructive font-medium" : ""}>
+ {formatDate(planDate)}
+ </div>
+ {daysUntilDue !== null && (
+ <div className="text-xs">
+ {isOverdue ? (
+ <Badge variant="destructive" className="gap-1">
+ <AlertCircle className="h-3 w-3" />
+ {Math.abs(daysUntilDue)} days overdue
+ </Badge>
+ ) : daysUntilDue === 0 ? (
+ <Badge variant="warning" className="gap-1">
+ <Clock className="h-3 w-3" />
+ Due today
+ </Badge>
+ ) : (
+ <span className="text-muted-foreground">
+ {daysUntilDue} days remaining
+ </span>
+ )}
+ </div>
+ )}
+ </div>
+ )
+ },
+ size: 150,
+ },
+ {
+ accessorKey: "latestSubmissionStatus",
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="Submission Status" />
+ ),
+ cell: ({ row }) => {
+ const status = row.getValue("latestSubmissionStatus") as string | null
+ const reviewStatus = row.original.latestReviewStatus
+ const revisionNumber = row.original.latestRevisionNumber
+ const revisionCode = row.original.latestRevisionCode
+
+ if (!status) {
+ return (
+ <Badge variant="outline" className="gap-1">
+ <AlertCircle className="h-3 w-3" />
+ Not submitted
+ </Badge>
+ )
+ }
+
+ return (
+ <div className="space-y-1">
+ <Badge
+ variant={
+ reviewStatus === "APPROVED" ? "success" :
+ reviewStatus === "REJECTED" ? "destructive" :
+ status === "SUBMITTED" ? "default" :
+ "secondary"
+ }
+ >
+ {reviewStatus || status}
+ </Badge>
+ {revisionCode !== null &&(
+ <div className="text-xs text-muted-foreground">
+ {revisionCode}
+ </div>
+ )}
+ </div>
+ )
+ },
+ size: 150,
+ },
+ {
+ id: "syncStatus",
+ accessorKey: "latestSyncStatus",
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="Sync Status" />
+ ),
+ cell: ({ row }) => {
+ const syncStatus = row.getValue("latestSyncStatus") as string | null
+ const syncProgress = row.original.syncProgress
+ const requiresSync = row.original.requiresSync
+
+ if (!syncStatus || syncStatus === "pending") {
+ if (requiresSync) {
+ return (
+ <Badge variant="outline" className="gap-1">
+ <Clock className="h-3 w-3" />
+ Pending
+ </Badge>
+ )
+ }
+ return <span className="text-muted-foreground">-</span>
+ }
+
+ return (
+ <div className="space-y-2">
+ <Badge
+ variant={
+ syncStatus === "synced" ? "success" :
+ syncStatus === "failed" ? "destructive" :
+ syncStatus === "syncing" ? "default" :
+ "secondary"
+ }
+ className="gap-1"
+ >
+ {syncStatus === "syncing" && <RefreshCw className="h-3 w-3 animate-spin" />}
+ {syncStatus === "synced" && <CheckCircle2 className="h-3 w-3" />}
+ {syncStatus === "failed" && <XCircle className="h-3 w-3" />}
+ {syncStatus}
+ </Badge>
+ {syncProgress !== null && syncProgress !== undefined && syncStatus === "syncing" && (
+ <Progress value={syncProgress} className="h-1.5 w-20" />
+ )}
+ </div>
+ )
+ },
+ size: 120,
+ },
+ {
+ accessorKey: "totalFiles",
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="Files" />
+ ),
+ cell: ({ row }) => {
+ const totalFiles = row.getValue("totalFiles") as number
+ const syncedFiles = row.original.syncedFilesCount
+
+ if (!totalFiles) return <span className="text-muted-foreground">0</span>
+
+ return (
+ <div className="text-sm">
+ {syncedFiles !== null && syncedFiles !== undefined ? (
+ <span>{syncedFiles}/{totalFiles}</span>
+ ) : (
+ <span>{totalFiles}</span>
+ )}
+ </div>
+ )
+ },
+ size: 80,
+ },
+ // {
+ // accessorKey: "vendorName",
+ // header: ({ column }) => (
+ // <DataTableColumnHeaderSimple column={column} title="Vendor" />
+ // ),
+ // cell: ({ row }) => {
+ // const vendorName = row.getValue("vendorName") as string
+ // const vendorCode = row.original.vendorCode
+
+ // return (
+ // <div className="space-y-1">
+ // <div className="text-sm">{vendorName}</div>
+ // {vendorCode && (
+ // <div className="text-xs text-muted-foreground">{vendorCode}</div>
+ // )}
+ // </div>
+ // )
+ // },
+ // size: 150,
+ // },
+ {
+ id: "actions",
+ enableHiding: false,
+ cell: function Cell({ row }) {
+ const requiresSubmission = row.original.requiresSubmission
+ const requiresSync = row.original.requiresSync
+ const latestSubmissionId = row.original.latestSubmissionId
+
+ return (
+ <DropdownMenu>
+ <DropdownMenuTrigger asChild>
+ <Button
+ aria-label="Open menu"
+ variant="ghost"
+ className="flex size-7 p-0"
+ >
+ <Ellipsis className="size-4" />
+ </Button>
+ </DropdownMenuTrigger>
+ <DropdownMenuContent align="end" className="w-48">
+ {requiresSubmission && (
+ <DropdownMenuItem
+ onSelect={() => setRowAction({ row, type: "upload" })}
+ className="gap-2"
+ >
+ <Upload className="h-4 w-4" />
+ Upload Documents
+ </DropdownMenuItem>
+ )}
+
+ {latestSubmissionId && (
+ <>
+ <DropdownMenuItem
+ onSelect={() => setRowAction({ row, type: "view" })}
+ className="gap-2"
+ >
+ <Eye className="h-4 w-4" />
+ View Submission
+ </DropdownMenuItem>
+
+ {requiresSync && (
+ <DropdownMenuItem
+ onSelect={() => setRowAction({ row, type: "sync" })}
+ className="gap-2"
+ >
+ <RefreshCw className="h-4 w-4" />
+ Retry Sync
+ </DropdownMenuItem>
+ )}
+ </>
+ )}
+
+ <DropdownMenuSeparator />
+
+ <DropdownMenuItem
+ onSelect={() => setRowAction({ row, type: "history" })}
+ className="gap-2"
+ >
+ <Clock className="h-4 w-4" />
+ View History
+ </DropdownMenuItem>
+ </DropdownMenuContent>
+ </DropdownMenu>
+ )
+ },
+ size: 40,
+ }
+ ]
+} \ No newline at end of file
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
diff --git a/lib/vendor-document-list/plant/upload/service.ts b/lib/vendor-document-list/plant/upload/service.ts
new file mode 100644
index 00000000..18e6c132
--- /dev/null
+++ b/lib/vendor-document-list/plant/upload/service.ts
@@ -0,0 +1,228 @@
+import db from "@/db/db"
+import { stageSubmissionView, StageSubmissionView } from "@/db/schema"
+import { and, asc, desc, eq, or, ilike, isTrue, sql, isNotNull, count } from "drizzle-orm"
+import { filterColumns } from "@/lib/filter-columns"
+import { GetStageSubmissionsSchema } from "./validation"
+import { getServerSession } from 'next-auth/next'
+import { authOptions } from '@/app/api/auth/[...nextauth]/route'
+import { redirect } from "next/navigation"
+
+// Repository functions (동일)
+async function selectStageSubmissions(
+ tx: typeof db,
+ params: {
+ where?: any
+ orderBy?: any
+ offset?: number
+ limit?: number
+ }
+) {
+ const { where, orderBy = [desc(stageSubmissionView.isOverdue)], offset = 0, limit = 10 } = params
+
+ const query = tx
+ .select()
+ .from(stageSubmissionView)
+ .$dynamic()
+
+ if (where) query.where(where)
+ if (orderBy) query.orderBy(...(Array.isArray(orderBy) ? orderBy : [orderBy]))
+ query.limit(limit).offset(offset)
+
+ return await query
+}
+
+async function countStageSubmissions(tx: typeof db, where?: any) {
+ const query = tx
+ .select({ count: count() })
+ .from(stageSubmissionView)
+ .$dynamic()
+
+ if (where) query.where(where)
+
+ const result = await query
+ return result[0]?.count ?? 0
+}
+
+// Service function with session check
+export async function getStageSubmissions(input: GetStageSubmissionsSchema) {
+ // Session 체크
+ const session = await getServerSession(authOptions)
+ if (!session?.user?.id) {
+ return {
+ success: false,
+ error: '로그인이 필요합니다.'
+ }
+ }
+ const vendorId = session.user.companyId // companyId가 vendorId
+
+ try {
+ const offset = (input.page - 1) * input.perPage
+
+ // Advanced filters
+ const advancedWhere = filterColumns({
+ table: stageSubmissionView,
+ filters: input.filters,
+ joinOperator: input.joinOperator,
+ })
+
+ // Global search
+ let globalWhere
+ if (input.search) {
+ const s = `%${input.search}%`
+ globalWhere = or(
+ ilike(stageSubmissionView.documentTitle, s),
+ ilike(stageSubmissionView.docNumber, s),
+ ilike(stageSubmissionView.vendorDocNumber, s),
+ ilike(stageSubmissionView.stageName, s)
+ // vendorName 검색 제거 (자기 회사만 보므로)
+ )
+ }
+
+ // Status filters
+ let statusWhere
+ if (input.submissionStatus && input.submissionStatus !== "all") {
+ switch (input.submissionStatus) {
+ case "required":
+ statusWhere = eq(stageSubmissionView.requiresSubmission, true)
+ break
+ case "submitted":
+ statusWhere = eq(stageSubmissionView.latestSubmissionStatus, "SUBMITTED")
+ break
+ case "approved":
+ statusWhere = eq(stageSubmissionView.latestReviewStatus, "APPROVED")
+ break
+ case "rejected":
+ statusWhere = eq(stageSubmissionView.latestReviewStatus, "REJECTED")
+ break
+ }
+ }
+
+ // Sync status filter
+ let syncWhere
+ if (input.syncStatus && input.syncStatus !== "all") {
+ if (input.syncStatus === "pending") {
+ syncWhere = or(
+ eq(stageSubmissionView.latestSyncStatus, "pending"),
+ eq(stageSubmissionView.requiresSync, true)
+ )
+ } else {
+ syncWhere = eq(stageSubmissionView.latestSyncStatus, input.syncStatus)
+ }
+ }
+
+ // Project filter
+ let projectWhere = input.projectId ? eq(stageSubmissionView.projectId, input.projectId) : undefined
+
+ // ✅ 벤더 필터 - session의 companyId 사용
+ const vendorWhere = eq(stageSubmissionView.vendorId, vendorId)
+
+ const finalWhere = and(
+ vendorWhere, // 항상 벤더 필터 적용
+ advancedWhere,
+ globalWhere,
+ statusWhere,
+ syncWhere,
+ projectWhere
+ )
+
+ const orderBy =
+ input.sort.length > 0
+ ? input.sort.map((item) =>
+ item.desc
+ ? desc(stageSubmissionView[item.id])
+ : asc(stageSubmissionView[item.id])
+ )
+ : [desc(stageSubmissionView.isOverdue), asc(stageSubmissionView.daysUntilDue)]
+
+ // Transaction
+ const { data, total } = await db.transaction(async (tx) => {
+ const data = await selectStageSubmissions(tx, {
+ where: finalWhere,
+ orderBy,
+ offset,
+ limit: input.perPage,
+ })
+ const total = await countStageSubmissions(tx, finalWhere)
+ return { data, total }
+ })
+
+ const pageCount = Math.ceil(total / input.perPage)
+
+ return { data, pageCount }
+ } catch (err) {
+ console.error("Error fetching stage submissions:", err)
+ return { data: [], pageCount: 0 }
+ }
+}
+
+// 프로젝트 목록 조회 - 벤더 필터 적용
+export async function getProjects() {
+ const session = await getServerSession(authOptions)
+ if (!session?.user?.id) {
+ return {
+ success: false,
+ error: '로그인이 필요합니다.'
+ }
+ }
+ if (!session?.user?.companyId) {
+ return []
+ }
+
+ const vendorId = session.user.companyId
+
+ const projects = await db
+ .selectDistinct({
+ id: stageSubmissionView.projectId,
+ code: stageSubmissionView.projectCode,
+ })
+ .from(stageSubmissionView)
+ .where(
+ and(
+ eq(stageSubmissionView.vendorId, vendorId),
+ isNotNull(stageSubmissionView.projectId)
+ )
+ )
+ .orderBy(asc(stageSubmissionView.projectCode))
+
+ return projects
+}
+
+// 통계 조회 - 벤더별
+export async function getSubmissionStats() {
+ const session = await getServerSession(authOptions)
+ if (!session?.user?.id) {
+ return {
+ success: false,
+ error: '로그인이 필요합니다.'
+ }
+ }
+
+
+ if (!session?.user?.companyId) {
+ return {
+ pending: 0,
+ overdue: 0,
+ awaitingSync: 0,
+ completed: 0,
+ }
+ }
+
+ const vendorId = session.user.companyId
+
+ const stats = await db
+ .select({
+ pending: sql<number>`count(*) filter (where ${stageSubmissionView.requiresSubmission} = true)::int`,
+ overdue: sql<number>`count(*) filter (where ${stageSubmissionView.isOverdue} = true)::int`,
+ awaitingSync: sql<number>`count(*) filter (where ${stageSubmissionView.requiresSync} = true)::int`,
+ completed: sql<number>`count(*) filter (where ${stageSubmissionView.latestReviewStatus} = 'APPROVED')::int`,
+ })
+ .from(stageSubmissionView)
+ .where(eq(stageSubmissionView.vendorId, vendorId))
+
+ return stats[0] || {
+ pending: 0,
+ overdue: 0,
+ awaitingSync: 0,
+ completed: 0,
+ }
+} \ No newline at end of file
diff --git a/lib/vendor-document-list/plant/upload/table.tsx b/lib/vendor-document-list/plant/upload/table.tsx
new file mode 100644
index 00000000..92507900
--- /dev/null
+++ b/lib/vendor-document-list/plant/upload/table.tsx
@@ -0,0 +1,223 @@
+// lib/vendor-document-list/plant/upload/table.tsx
+"use client"
+
+import * as React from "react"
+import type {
+ DataTableAdvancedFilterField,
+ DataTableFilterField,
+ DataTableRowAction,
+} from "@/types/table"
+
+import { useDataTable } from "@/hooks/use-data-table"
+import { DataTable } from "@/components/data-table/data-table"
+import { DataTableAdvancedToolbar } from "@/components/data-table/data-table-advanced-toolbar"
+import { getColumns } from "./columns"
+import { getStageSubmissions } from "./service"
+import { StageSubmissionView } from "@/db/schema"
+import { StageSubmissionToolbarActions } from "./toolbar-actions"
+import { useRouter, useSearchParams, usePathname } from "next/navigation"
+import { ProjectFilter } from "./components/project-filter"
+import { SingleUploadDialog } from "./components/single-upload-dialog"
+import { HistoryDialog } from "./components/history-dialog"
+import { ViewSubmissionDialog } from "./components/view-submission-dialog"
+
+interface StageSubmissionsTableProps {
+ promises: Promise<[
+ Awaited<ReturnType<typeof getStageSubmissions>>,
+ { projects: Array<{ id: number; code: string }> }
+ ]>
+ selectedProjectId?: number | null
+}
+
+export function StageSubmissionsTable({ promises, selectedProjectId }: StageSubmissionsTableProps) {
+ const [{ data, pageCount }, { projects }] = React.use(promises)
+ const router = useRouter()
+ const pathname = usePathname()
+ const searchParams = useSearchParams()
+
+ const [rowAction, setRowAction] = React.useState<DataTableRowAction<StageSubmissionView> | null>(null)
+
+ const columns = React.useMemo(
+ () => getColumns({ setRowAction }),
+ [setRowAction]
+ )
+
+ // 프로젝트 필터 핸들러
+ const handleProjectChange = (projectId: number | null) => {
+ const current = new URLSearchParams(Array.from(searchParams.entries()))
+
+ if (projectId) {
+ current.set("projectId", projectId.toString())
+ } else {
+ current.delete("projectId")
+ }
+
+ // 페이지를 1로 리셋
+ current.set("page", "1")
+
+ const search = current.toString()
+ const query = search ? `?${search}` : ""
+
+ router.push(`${pathname}${query}`)
+ }
+
+ // Filter fields - 프로젝트 필터 제거
+ const filterFields: DataTableFilterField<StageSubmissionView>[] = [
+ {
+ id: "stageStatus",
+ label: "Stage Status",
+ options: [
+ { label: "Planned", value: "PLANNED" },
+ { label: "In Progress", value: "IN_PROGRESS" },
+ { label: "Submitted", value: "SUBMITTED" },
+ { label: "Approved", value: "APPROVED" },
+ { label: "Rejected", value: "REJECTED" },
+ { label: "Completed", value: "COMPLETED" },
+ ]
+ },
+ {
+ id: "latestSubmissionStatus",
+ label: "Submission Status",
+ options: [
+ { label: "Submitted", value: "SUBMITTED" },
+ { label: "Under Review", value: "UNDER_REVIEW" },
+ { label: "Draft", value: "DRAFT" },
+ { label: "Withdrawn", value: "WITHDRAWN" },
+ ]
+ },
+ {
+ id: "requiresSubmission",
+ label: "Requires Submission",
+ options: [
+ { label: "Yes", value: "true" },
+ { label: "No", value: "false" },
+ ]
+ },
+ {
+ id: "requiresSync",
+ label: "Requires Sync",
+ options: [
+ { label: "Yes", value: "true" },
+ { label: "No", value: "false" },
+ ]
+ },
+ {
+ id: "isOverdue",
+ label: "Overdue",
+ options: [
+ { label: "Yes", value: "true" },
+ { label: "No", value: "false" },
+ ]
+ }
+ ]
+
+ const advancedFilterFields: DataTableAdvancedFilterField<StageSubmissionView>[] = [
+ {
+ id: "docNumber",
+ label: "Doc Number",
+ type: "text",
+ },
+ {
+ id: "documentTitle",
+ label: "Document Title",
+ type: "text",
+ },
+ {
+ id: "stageName",
+ label: "Stage Name",
+ type: "text",
+ },
+ {
+ id: "stagePlanDate",
+ label: "Due Date",
+ type: "date",
+ },
+ {
+ id: "daysUntilDue",
+ label: "Days Until Due",
+ type: "number",
+ },
+ ]
+
+ const { table } = useDataTable({
+ data,
+ columns,
+ pageCount,
+ filterFields,
+ enablePinning: true,
+ enableAdvancedFilter: true,
+ initialState: {
+ sorting: [
+ { id: "isOverdue", desc: true },
+ { id: "daysUntilDue", desc: false }
+ ],
+ columnPinning: { right: ["actions"] },
+ },
+ getRowId: (originalRow) => `${originalRow.documentId}-${originalRow.stageId}`,
+ shallow: false,
+ clearOnDefault: true,
+ columnResizeMode: "onEnd",
+ })
+
+ return (
+ <>
+ <DataTable table={table}>
+ {/* 프로젝트 필터를 툴바 위에 배치 */}
+ <div className="flex items-center justify-between pb-3">
+ <ProjectFilter
+ projects={projects}
+ value={selectedProjectId}
+ onValueChange={handleProjectChange}
+ />
+ <div className="text-sm text-muted-foreground">
+ {data.length} record(s) found
+ </div>
+ </div>
+
+ <DataTableAdvancedToolbar
+ table={table}
+ filterFields={advancedFilterFields}
+ shallow={false}
+ >
+ <StageSubmissionToolbarActions
+ table={table}
+ rowAction={rowAction}
+ setRowAction={setRowAction}
+ />
+ </DataTableAdvancedToolbar>
+ </DataTable>
+
+ {/* Upload Dialog */}
+ {rowAction?.type === "upload" && (
+ <SingleUploadDialog
+ open={true}
+ onOpenChange={(open) => !open && setRowAction(null)}
+ submission={rowAction.row.original}
+ onUploadComplete={() => {
+ setRowAction(null)
+ // 테이블 새로고침
+ window.location.reload()
+ }}
+ />
+ )}
+
+ {/* View Submission Dialog */}
+ {rowAction?.type === "view" && (
+ <ViewSubmissionDialog
+ open={true}
+ onOpenChange={(open) => !open && setRowAction(null)}
+ submission={rowAction.row.original}
+ />
+ )}
+
+ {/* History Dialog */}
+ {rowAction?.type === "history" && (
+ <HistoryDialog
+ open={true}
+ onOpenChange={(open) => !open && setRowAction(null)}
+ submission={rowAction.row.original}
+ />
+ )}
+ </>
+ )
+} \ No newline at end of file
diff --git a/lib/vendor-document-list/plant/upload/toolbar-actions.tsx b/lib/vendor-document-list/plant/upload/toolbar-actions.tsx
new file mode 100644
index 00000000..072fd72d
--- /dev/null
+++ b/lib/vendor-document-list/plant/upload/toolbar-actions.tsx
@@ -0,0 +1,242 @@
+"use client"
+
+import * as React from "react"
+import { type Table } from "@tanstack/react-table"
+import { Download, RefreshCw, Upload, Send, AlertCircle } from "lucide-react"
+import { toast } from "sonner"
+
+import { exportTableToExcel } from "@/lib/export"
+import { Button } from "@/components/ui/button"
+import {
+ AlertDialog,
+ AlertDialogAction,
+ AlertDialogCancel,
+ AlertDialogContent,
+ AlertDialogDescription,
+ AlertDialogFooter,
+ AlertDialogHeader,
+ AlertDialogTitle,
+} from "@/components/ui/alert-dialog"
+import { StageSubmissionView } from "@/db/schema"
+import { DataTableRowAction } from "@/types/table"
+import { MultiUploadDialog } from "./components/multi-upload-dialog"
+import { useRouter, useSearchParams } from "next/navigation"
+
+interface StageSubmissionToolbarActionsProps {
+ table: Table<StageSubmissionView>
+ rowAction: DataTableRowAction<StageSubmissionView> | null
+ setRowAction: React.Dispatch<React.SetStateAction<DataTableRowAction<StageSubmissionView> | null>>
+}
+
+export function StageSubmissionToolbarActions({
+ table,
+ rowAction,
+ setRowAction
+}: StageSubmissionToolbarActionsProps) {
+ const selectedRows = table.getFilteredSelectedRowModel().rows
+ const router = useRouter()
+ const searchParams = useSearchParams()
+
+ const projectId = searchParams.get('projectId')
+
+
+ const [isSyncing, setIsSyncing] = React.useState(false)
+ const [showSyncDialog, setShowSyncDialog] = React.useState(false)
+ const [syncTargets, setSyncTargets] = React.useState<typeof selectedRows>([])
+
+ const handleUploadComplete = () => {
+ // Refresh table
+ router.refresh()
+ }
+
+ const handleSyncClick = () => {
+ const rowsRequiringSync = selectedRows.filter(
+ row => row.original.requiresSync && row.original.latestSubmissionId
+ )
+ setSyncTargets(rowsRequiringSync)
+ setShowSyncDialog(true)
+ }
+
+ const handleSyncConfirm = async () => {
+ setShowSyncDialog(false)
+ setIsSyncing(true)
+
+ try {
+ // Extract submission IDs
+ const submissionIds = syncTargets
+ .map(row => row.original.latestSubmissionId)
+ .filter((id): id is number => id !== null)
+
+ if (submissionIds.length === 0) {
+ toast.error("No submissions to sync.")
+ return
+ }
+
+ // API call
+ const response = await fetch('/api/stage-submissions/sync', {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json',
+ },
+ body: JSON.stringify({ submissionIds }),
+ })
+
+ const result = await response.json()
+
+ if (result.success) {
+ toast.success(result.message)
+
+ // Display detailed information for successful items
+ if (result.results?.details) {
+ const successCount = result.results.details.filter((d: any) => d.success).length
+ const failedCount = result.results.details.filter((d: any) => !d.success).length
+
+ if (failedCount > 0) {
+ toast.warning(`${successCount} succeeded, ${failedCount} failed`)
+ }
+ }
+
+ // Refresh table
+ router.refresh()
+ table.toggleAllPageRowsSelected(false) // Deselect all
+ } else {
+ toast.error(result.error || "Sync failed")
+ }
+ } catch (error) {
+ console.error("Sync error:", error)
+ toast.error("An error occurred during synchronization.")
+ } finally {
+ setIsSyncing(false)
+ }
+ }
+
+ return (
+ <>
+ <div className="flex items-center gap-2">
+ {projectId && (
+ <MultiUploadDialog
+ projectId={parseInt(projectId)}
+ // projectCode={projectCode}
+ onUploadComplete={handleUploadComplete}
+ />
+ )}
+ {selectedRows.length > 0 && (
+ <>
+ {/* Bulk Upload for selected rows that require submission */}
+ {selectedRows.some(row => row.original.requiresSubmission) && (
+ <Button
+ variant="outline"
+ size="sm"
+ onClick={() => {
+ // Filter selected rows that require submission
+ const rowsRequiringSubmission = selectedRows.filter(
+ row => row.original.requiresSubmission
+ )
+ // Open bulk upload dialog
+ console.log("Bulk upload for:", rowsRequiringSubmission)
+ }}
+ className="gap-2"
+ >
+ <Upload className="size-4" />
+ <span>Upload ({selectedRows.filter(r => r.original.requiresSubmission).length})</span>
+ </Button>
+ )}
+
+ {/* Bulk Sync for selected rows that need syncing */}
+ {selectedRows.some(row => row.original.requiresSync && row.original.latestSubmissionId) && (
+ <Button
+ variant="outline"
+ size="sm"
+ onClick={handleSyncClick}
+ disabled={isSyncing}
+ className="gap-2"
+ >
+ {isSyncing ? (
+ <>
+ <RefreshCw className="size-4 animate-spin" />
+ <span>Syncing...</span>
+ </>
+ ) : (
+ <>
+ <RefreshCw className="size-4" />
+ <span>Sync ({selectedRows.filter(r => r.original.requiresSync && r.original.latestSubmissionId).length})</span>
+ </>
+ )}
+ </Button>
+ )}
+ </>
+ )}
+
+ {/* Export Button */}
+ <Button
+ variant="outline"
+ size="sm"
+ onClick={() =>
+ exportTableToExcel(table, {
+ filename: `stage-submissions-${new Date().toISOString().split('T')[0]}`,
+ excludeColumns: ["select", "actions"],
+ })
+ }
+ className="gap-2"
+ >
+ <Download className="size-4" />
+ <span className="hidden sm:inline">Export</span>
+ </Button>
+ </div>
+
+ {/* Sync Confirmation Dialog */}
+ <AlertDialog open={showSyncDialog} onOpenChange={setShowSyncDialog}>
+ <AlertDialogContent>
+ <AlertDialogHeader>
+ <AlertDialogTitle className="flex items-center gap-2">
+ <RefreshCw className="size-5" />
+ Sync to Buyer System
+ </AlertDialogTitle>
+ <AlertDialogDescription className="space-y-3">
+ <div>
+ Are you sure you want to sync {syncTargets.length} selected submission(s) to the buyer system?
+ </div>
+ <div className="space-y-2 rounded-lg bg-muted p-3">
+ <div className="text-sm font-medium">Items to sync:</div>
+ <ul className="text-sm space-y-1">
+ {syncTargets.slice(0, 3).map((row, idx) => (
+ <li key={idx} className="flex items-center gap-2">
+ <span className="text-muted-foreground">•</span>
+ <span>{row.original.docNumber}</span>
+ <span className="text-muted-foreground">-</span>
+ <span>{row.original.stageName}</span>
+ <span className="text-muted-foreground">
+ (Rev.{row.original.latestRevisionNumber})
+ </span>
+ </li>
+ ))}
+ {syncTargets.length > 3 && (
+ <li className="text-muted-foreground">
+ ... and {syncTargets.length - 3} more
+ </li>
+ )}
+ </ul>
+ </div>
+ <div className="flex items-start gap-2 text-sm text-amber-600">
+ <AlertCircle className="size-4 mt-0.5 shrink-0" />
+ <div>
+ Synchronized files will be sent to the SHI Buyer System and
+ cannot be recalled after transmission.
+ </div>
+ </div>
+ </AlertDialogDescription>
+ </AlertDialogHeader>
+ <AlertDialogFooter>
+ <AlertDialogCancel>Cancel</AlertDialogCancel>
+ <AlertDialogAction
+ onClick={handleSyncConfirm}
+ // className="bg-samsung hover:bg-samsung/90"
+ >
+ Start Sync
+ </AlertDialogAction>
+ </AlertDialogFooter>
+ </AlertDialogContent>
+ </AlertDialog>
+ </>
+ )
+} \ No newline at end of file
diff --git a/lib/vendor-document-list/plant/upload/util/filie-parser.ts b/lib/vendor-document-list/plant/upload/util/filie-parser.ts
new file mode 100644
index 00000000..42dac9b4
--- /dev/null
+++ b/lib/vendor-document-list/plant/upload/util/filie-parser.ts
@@ -0,0 +1,132 @@
+// lib/vendor-document-list/plant/upload/utils/file-parser.ts
+
+export interface ParsedFileName {
+ docNumber: string
+ stageName: string
+ revision: string
+ extension: string
+ originalName: string
+ isValid: boolean
+ error?: string
+}
+
+export function parseFileName(fileName: string): ParsedFileName {
+ try {
+ // 확장자 분리
+ const lastDotIndex = fileName.lastIndexOf('.')
+ if (lastDotIndex === -1) {
+ return {
+ docNumber: '',
+ stageName: '',
+ revision: '',
+ extension: '',
+ originalName: fileName,
+ isValid: false,
+ error: 'No file extension found'
+ }
+ }
+
+ const extension = fileName.substring(lastDotIndex + 1)
+ const nameWithoutExt = fileName.substring(0, lastDotIndex)
+
+ // 언더스코어로 분리 (최소 3개 부분 필요)
+ const parts = nameWithoutExt.split('_')
+
+ if (parts.length < 3) {
+ return {
+ docNumber: '',
+ stageName: '',
+ revision: '',
+ extension,
+ originalName: fileName,
+ isValid: false,
+ error: `Invalid format. Expected: DocNumber_StageName_Revision.${extension}`
+ }
+ }
+
+ // 파싱 결과
+ const docNumber = parts[0]
+ const stageName = parts.slice(1, -1).join('_') // 중간 부분이 여러 개일 수 있음
+ const revision = parts[parts.length - 1] // 마지막 부분이 리비전
+
+ // 기본 검증
+ if (!docNumber || !stageName || !revision) {
+ return {
+ docNumber: '',
+ stageName: '',
+ revision: '',
+ extension,
+ originalName: fileName,
+ isValid: false,
+ error: 'Missing required parts'
+ }
+ }
+
+ return {
+ docNumber,
+ stageName,
+ revision,
+ extension,
+ originalName: fileName,
+ isValid: true
+ }
+ } catch (error) {
+ return {
+ docNumber: '',
+ stageName: '',
+ revision: '',
+ extension: '',
+ originalName: fileName,
+ isValid: false,
+ error: 'Failed to parse filename'
+ }
+ }
+}
+
+// 리비전 번호 추출 (숫자 우선, 없으면 문자를 숫자로 변환)
+export function extractRevisionNumber(revision: string): number {
+ const cleanRevision = revision.toLowerCase().replace(/[^a-z0-9]/g, '')
+
+ // Rev0, Rev1 형식
+ const revMatch = cleanRevision.match(/rev(\d+)/)
+ if (revMatch) return parseInt(revMatch[1])
+
+ // R0, R1 형식
+ const rMatch = cleanRevision.match(/r(\d+)/)
+ if (rMatch) return parseInt(rMatch[1])
+
+ // v1, v2 형식
+ const vMatch = cleanRevision.match(/v(\d+)/)
+ if (vMatch) return parseInt(vMatch[1])
+
+ // 단순 숫자
+ const numMatch = cleanRevision.match(/^(\d+)$/)
+ if (numMatch) return parseInt(numMatch[1])
+
+ // RevA, RevB 또는 A, B 형식 -> 숫자로 변환 (A=1, B=2, etc.)
+ const alphaMatch = cleanRevision.match(/^(?:rev)?([a-z])$/i)
+ if (alphaMatch) {
+ return alphaMatch[1].toUpperCase().charCodeAt(0) - 64 // A=1, B=2, C=3...
+ }
+
+ // 기본값
+ return 0
+}
+
+// 리비전 코드 정규화 (DB 저장용)
+export function normalizeRevisionCode(revision: string): string {
+ // Rev0 -> 0, RevA -> A, v1 -> 1 등으로 정규화
+ const cleanRevision = revision.toLowerCase()
+
+ // Rev 제거
+ if (cleanRevision.startsWith('rev')) {
+ return revision.substring(3)
+ }
+
+ // R, v 제거
+ if (cleanRevision.startsWith('r') || cleanRevision.startsWith('v')) {
+ return revision.substring(1)
+ }
+
+ return revision
+} \ No newline at end of file
diff --git a/lib/vendor-document-list/plant/upload/validation.ts b/lib/vendor-document-list/plant/upload/validation.ts
new file mode 100644
index 00000000..80a7d390
--- /dev/null
+++ b/lib/vendor-document-list/plant/upload/validation.ts
@@ -0,0 +1,35 @@
+import {
+ createSearchParamsCache,
+ parseAsArrayOf,
+ parseAsInteger,
+ parseAsString,
+ parseAsStringEnum,
+ } from "nuqs/server"
+ import * as z from "zod"
+
+ import { getFiltersStateParser, getSortingStateParser } from "@/lib/parsers"
+ import { StageSubmissionView } from "@/db/schema"
+
+ export const searchParamsCache = createSearchParamsCache({
+ flags: parseAsArrayOf(z.enum(["advancedTable", "floatingBar"])).withDefault(
+ []
+ ),
+ page: parseAsInteger.withDefault(1),
+ perPage: parseAsInteger.withDefault(20),
+ sort: getSortingStateParser<StageSubmissionView>().withDefault([
+ { id: "isOverdue", desc: true },
+ { id: "daysUntilDue", desc: false },
+ ]),
+
+ // advanced filter
+ filters: getFiltersStateParser().withDefault([]),
+ joinOperator: parseAsStringEnum(["and", "or"]).withDefault("and"),
+ search: parseAsString.withDefault(""),
+
+ // 프로젝트 필터만 유지
+ projectId: parseAsInteger,
+ syncStatus: parseAsStringEnum(["all", "pending", "syncing", "synced", "failed", "partial"]).withDefault("all"),
+ submissionStatus: parseAsStringEnum(["all", "required", "submitted", "approved", "rejected"]).withDefault("all"),
+ })
+
+ export type GetStageSubmissionsSchema = Awaited<ReturnType<typeof searchParamsCache.parse>> \ No newline at end of file