summaryrefslogtreecommitdiff
path: root/lib/vendor-document-list
diff options
context:
space:
mode:
Diffstat (limited to 'lib/vendor-document-list')
-rw-r--r--lib/vendor-document-list/import-service.ts8
-rw-r--r--lib/vendor-document-list/plant/document-stage-dialogs.tsx28
-rw-r--r--lib/vendor-document-list/plant/document-stage-toolbar.tsx114
-rw-r--r--lib/vendor-document-list/plant/document-stages-columns.tsx86
-rw-r--r--lib/vendor-document-list/plant/document-stages-expanded-content.tsx4
-rw-r--r--lib/vendor-document-list/plant/document-stages-service.ts123
-rw-r--r--lib/vendor-document-list/plant/document-stages-table.tsx20
-rw-r--r--lib/vendor-document-list/plant/excel-import-export.ts6
-rw-r--r--lib/vendor-document-list/plant/shi-buyer-system-api.ts874
-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
20 files changed, 3976 insertions, 56 deletions
diff --git a/lib/vendor-document-list/import-service.ts b/lib/vendor-document-list/import-service.ts
index ede2963f..13c51824 100644
--- a/lib/vendor-document-list/import-service.ts
+++ b/lib/vendor-document-list/import-service.ts
@@ -506,7 +506,7 @@ class ImportService {
// DOLCE FileInfo API 응답 구조에 맞게 처리
if (data.FileInfoListResult) {
const files = data.FileInfoListResult as DOLCEFileInfo[]
- const activeFiles = files.filter(f => f.UseYn === 'Y')
+ const activeFiles = files.filter(f => f.UseYn === 'True')
debugSuccess(`DOLCE 파일 정보 조회 완료`, {
uploadId,
totalFiles: files.length,
@@ -885,7 +885,7 @@ class ImportService {
const fileInfos = await this.fetchFileInfoFromDOLCE(detailDoc.UploadId)
for (const fileInfo of fileInfos) {
- if (fileInfo.UseYn !== 'Y') {
+ if (fileInfo.UseYn !== 'True') {
debugProcess(`비활성 파일 스킵`, { fileName: fileInfo.FileName })
continue
}
@@ -1578,10 +1578,10 @@ async getImportStatus(
if (detailDoc.Category === 'FS' && detailDoc.UploadId) {
try {
const fileInfos = await this.fetchFileInfoFromDOLCE(detailDoc.UploadId)
- availableAttachments += fileInfos.filter(f => f.UseYn === 'Y').length
+ availableAttachments += fileInfos.filter(f => f.UseYn === 'True').length
for (const fileInfo of fileInfos) {
- if (fileInfo.UseYn !== 'Y') continue
+ if (fileInfo.UseYn !== 'True') continue
const existingAttachment = await db
.select({ id: documentAttachments.id })
diff --git a/lib/vendor-document-list/plant/document-stage-dialogs.tsx b/lib/vendor-document-list/plant/document-stage-dialogs.tsx
index 26f6b638..f49d7d47 100644
--- a/lib/vendor-document-list/plant/document-stage-dialogs.tsx
+++ b/lib/vendor-document-list/plant/document-stage-dialogs.tsx
@@ -31,7 +31,7 @@ import {
SelectValue,
} from "@/components/ui/select"
import { Badge } from "@/components/ui/badge"
-import { DocumentStagesOnlyView } from "@/db/schema"
+import { StageDocumentsView } from "@/db/schema"
import { Upload, FileSpreadsheet, Calendar, User, Target, Loader2, AlertTriangle, Loader ,Trash, CheckCircle, Download, AlertCircle} from "lucide-react"
import { toast } from "sonner"
import {
@@ -109,11 +109,10 @@ export function AddDocumentDialog({
const [selectedTypeConfigs, setSelectedTypeConfigs] = React.useState<any[]>([])
const [comboBoxOptions, setComboBoxOptions] = React.useState<Record<number, any[]>>({})
const [documentClassOptions, setDocumentClassOptions] = React.useState<any[]>([])
+ const [isLoadingInitialData, setIsLoadingInitialData] = React.useState(false)
+ const [isSubmitting, setIsSubmitting] = React.useState(false)
- console.log(documentNumberTypes, "documentNumberTypes")
- console.log(documentClassOptions, "documentClassOptions")
-
const [formData, setFormData] = React.useState({
documentNumberTypeId: "",
documentClassId: "",
@@ -126,12 +125,13 @@ export function AddDocumentDialog({
// Load initial data
React.useEffect(() => {
if (open) {
+ resetForm() // 폼 리셋 추가
loadInitialData()
}
}, [open])
const loadInitialData = async () => {
- setIsLoading(true)
+ setIsLoadingInitialData(true) // isLoading 대신
try {
const [typesResult, classesResult] = await Promise.all([
getDocumentNumberTypes(contractId),
@@ -147,7 +147,7 @@ export function AddDocumentDialog({
} catch (error) {
toast.error("Error loading data.")
} finally {
- setIsLoading(false)
+ setIsLoadingInitialData(false)
}
}
@@ -284,7 +284,7 @@ export function AddDocumentDialog({
return
}
- setIsLoading(true)
+ setIsSubmitting(true) // isLoading 대신
try {
const result = await createDocument({
contractId,
@@ -307,7 +307,7 @@ export function AddDocumentDialog({
} catch (error) {
toast.error("Error adding document.")
} finally {
- setIsLoading(false)
+ setIsSubmitting(false) // isLoading 대신
}
}
@@ -513,11 +513,11 @@ export function AddDocumentDialog({
)}
<DialogFooter className="flex-shrink-0">
- <Button variant="outline" onClick={() => onOpenChange(false)} disabled={isLoading}>
+ <Button variant="outline" onClick={() => onOpenChange(false)} disabled={isSubmitting}>
Cancel
</Button>
- <Button onClick={handleSubmit} disabled={isLoading || !isFormValid()}>
- {isLoading ? <Loader2 className="h-4 w-4 animate-spin mr-2" /> : null}
+ <Button onClick={handleSubmit} disabled={isSubmitting || !isFormValid()}>
+ {isSubmitting ? <Loader2 className="h-4 w-4 animate-spin mr-2" /> : null}
Add Document
</Button>
</DialogFooter>
@@ -532,7 +532,7 @@ export function AddDocumentDialog({
interface EditDocumentDialogProps {
open: boolean
onOpenChange: (open: boolean) => void
- document: DocumentStagesOnlyView | null
+ document: StageDocumentsView | null
contractId: number
projectType: "ship" | "plant"
}
@@ -753,7 +753,7 @@ export function EditDocumentDialog({
interface EditStageDialogProps {
open: boolean
onOpenChange: (open: boolean) => void
- document: DocumentStagesOnlyView | null
+ document: StageDocumentsView | null
stageId: number | null
}
@@ -1290,7 +1290,7 @@ export function ExcelImportDialog({
interface DeleteDocumentsDialogProps
extends React.ComponentPropsWithoutRef<typeof Dialog> {
- documents: Row<DocumentStagesOnlyView>["original"][]
+ documents: Row<StageDocumentsView>["original"][]
showTrigger?: boolean
onSuccess?: () => void
}
diff --git a/lib/vendor-document-list/plant/document-stage-toolbar.tsx b/lib/vendor-document-list/plant/document-stage-toolbar.tsx
index 87b221b7..601a9152 100644
--- a/lib/vendor-document-list/plant/document-stage-toolbar.tsx
+++ b/lib/vendor-document-list/plant/document-stage-toolbar.tsx
@@ -1,11 +1,10 @@
"use client"
import * as React from "react"
-import { type DocumentStagesOnlyView } from "@/db/schema"
+import { type StageDocumentsView } from "@/db/schema"
import { type Table } from "@tanstack/react-table"
-import { Download, Upload, Plus, FileSpreadsheet } from "lucide-react"
+import { Download, RefreshCw, Send, CheckCircle, AlertCircle, Plus, FileSpreadsheet, Loader2 } from "lucide-react"
import { toast } from "sonner"
-
import { exportTableToExcel } from "@/lib/export"
import { Button } from "@/components/ui/button"
@@ -15,12 +14,17 @@ import {
AddDocumentDialog,
ExcelImportDialog
} from "./document-stage-dialogs"
+import { sendDocumentsToSHI } from "./document-stages-service"
+import { useDocumentPolling } from "@/hooks/use-document-polling"
+import { cn } from "@/lib/utils"
+import { MultiUploadDialog } from "./upload/components/multi-upload-dialog"
+// import { useRouter } from "next/navigation"
// 서버 액션 import (필요한 경우)
// import { importDocumentsExcel } from "./document-stages-service"
interface DocumentsTableToolbarActionsProps {
- table: Table<DocumentStagesOnlyView>
+ table: Table<StageDocumentsView>
contractId: number
projectType: "ship" | "plant"
}
@@ -33,6 +37,43 @@ export function DocumentsTableToolbarActions({
// 다이얼로그 상태 관리
const [showAddDialog, setShowAddDialog] = React.useState(false)
const [showExcelImportDialog, setShowExcelImportDialog] = React.useState(false)
+ const [isSending, setIsSending] = React.useState(false)
+ const router = useRouter()
+
+ // 자동 폴링 훅 사용
+ const {
+ isPolling,
+ lastPolledAt,
+ pollingStatus,
+ pollDocuments
+ } = useDocumentPolling({
+ contractId,
+ autoStart: true,
+ onUpdate: () => {
+ // 테이블 새로고침
+ router.refresh()
+ }
+ })
+
+ async function handleSendToSHI() {
+ setIsSending(true)
+ try {
+ const result = await sendDocumentsToSHI(contractId)
+
+ if (result.success) {
+ toast.success(result.message)
+ router.refresh()
+ // 테이블 새로고침
+ } else {
+ toast.error(result.message)
+ }
+ } catch (error) {
+ toast.error("전송 중 오류가 발생했습니다.")
+ } finally {
+ setIsSending(false)
+ }
+ }
+
const handleExcelImport = () => {
setShowExcelImportDialog(true)
@@ -50,17 +91,28 @@ export function DocumentsTableToolbarActions({
})
}
+
+
+
return (
<div className="flex items-center gap-2">
+
+
+
{/* 1) 선택된 문서가 있으면 삭제 다이얼로그 */}
- {table.getFilteredSelectedRowModel().rows.length > 0 ? (
- <DeleteDocumentsDialog
- documents={table
- .getFilteredSelectedRowModel()
- .rows.map((row) => row.original)}
- onSuccess={() => table.toggleAllRowsSelected(false)}
- />
- ) : null}
+ {(() => {
+ const selectedRows = table.getFilteredSelectedRowModel().rows;
+ const deletableDocuments = selectedRows
+ .map((row) => row.original)s
+ .filter((doc) => !doc.buyerSystemStatus); // buyerSystemStatus가 null인 것만 필터링
+
+ return deletableDocuments.length > 0 ? (
+ <DeleteDocumentsDialog
+ documents={deletableDocuments}
+ onSuccess={() => table.toggleAllRowsSelected(false)}
+ />
+ ) : null;
+ })()}
{/* 2) 새 문서 추가 다이얼로그 */}
@@ -76,9 +128,45 @@ export function DocumentsTableToolbarActions({
projectType={projectType}
/>
+ {/* SHI 전송 버튼 */}
+ <Button
+ variant="samsung"
+ size="sm"
+ onClick={handleSendToSHI}
+ disabled={isSending}
+ className="gap-2"
+ >
+ {isSending ? (
+ <>
+ <Loader2 className="h-4 w-4 animate-spin" />
+ Sending..
+ </>
+ ) : (
+ <>
+ <Send className="h-4 w-4" />
+ Send to SHI
+ </>
+ )}
+ </Button>
+
+ <Button
+ variant="outline"
+ size="sm"
+ onClick={() => pollDocuments(true)}
+ disabled={isPolling}
+ className="gap-2"
+ >
+ <RefreshCw className={cn(
+ "h-4 w-4",
+ isPolling && "animate-spin"
+ )} />
+ Sync from SHI
+ </Button>
+
+
<Button onClick={handleExcelImport} variant="outline" size="sm">
<FileSpreadsheet className="mr-2 h-4 w-4" />
- Excel Import
+ Excel Import
</Button>
<ExcelImportDialog
diff --git a/lib/vendor-document-list/plant/document-stages-columns.tsx b/lib/vendor-document-list/plant/document-stages-columns.tsx
index aee47029..2f8fd482 100644
--- a/lib/vendor-document-list/plant/document-stages-columns.tsx
+++ b/lib/vendor-document-list/plant/document-stages-columns.tsx
@@ -6,7 +6,7 @@ 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 { DocumentStagesOnlyView } from "@/db/schema"
+import { StageDocumentsView } from "@/db/schema"
import {
DropdownMenu,
DropdownMenuContent,
@@ -28,12 +28,17 @@ import {
Eye,
Edit,
Plus,
- Trash2
+ Trash2,MessageSquare
} from "lucide-react"
import { cn } from "@/lib/utils"
+import {
+ Tooltip,
+ TooltipContent,
+ TooltipTrigger,
+} from "@/components/ui/tooltip"
interface GetColumnsProps {
- setRowAction: React.Dispatch<React.SetStateAction<DataTableRowAction<DocumentStagesOnlyView> | null>>
+ setRowAction: React.Dispatch<React.SetStateAction<DataTableRowAction<StageDocumentsView> | null>>
projectType: string
domain?: "evcp" | "partners" // 선택적 파라미터로 유지
}
@@ -139,11 +144,11 @@ export function getDocumentStagesColumns({
setRowAction,
projectType,
domain = "partners", // 기본값 설정
-}: GetColumnsProps): ColumnDef<DocumentStagesOnlyView>[] {
+}: GetColumnsProps): ColumnDef<StageDocumentsView>[] {
const isPlantProject = projectType === "plant"
const isEvcpDomain = domain === "evcp"
- const columns: ColumnDef<DocumentStagesOnlyView>[] = [
+ const columns: ColumnDef<StageDocumentsView>[] = [
// 체크박스 선택
{
id: "select",
@@ -315,6 +320,75 @@ export function getDocumentStagesColumns({
// 나머지 공통 컬럼들
columns.push(
// 현재 스테이지 (상태, 담당자 한 줄)
+
+ {
+ accessorKey: "status",
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="Document Status" />
+ ),
+ cell: ({ row }) => {
+ const doc = row.original
+
+ return (
+ <div className="flex items-center gap-2">
+ <Badge
+ variant={getStatusColor(doc.status || false)}
+ className="text-xs px-1.5 py-0"
+ >
+ {getStatusText(doc.status || '')}
+ </Badge>
+ </div>
+ )
+ },
+ size: 180,
+ enableResizing: true,
+ meta: {
+ excelHeader: "Document Status"
+ },
+ },
+
+ {
+ accessorKey: "buyerSystemStatus",
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="SHI Status" />
+ ),
+ cell: ({ row }) => {
+ const doc = row.original
+ const getBuyerStatusBadge = () => {
+ if (!doc.buyerSystemStatus) {
+ return <Badge variant="outline">Not Recieved</Badge>
+ }
+
+ switch (doc.buyerSystemStatus) {
+ case '승인(DC)':
+ return <Badge variant="success">Approved</Badge>
+ case '검토중':
+ return <Badge variant="default">검토중</Badge>
+ case '반려':
+ return <Badge variant="destructive">반려</Badge>
+ default:
+ return <Badge variant="secondary">{doc.buyerSystemStatus}</Badge>
+ }
+ }
+
+ return (
+ <div className="flex flex-col gap-1">
+ {getBuyerStatusBadge()}
+ {doc.buyerSystemComment && (
+ <Tooltip>
+ <TooltipTrigger>
+ <MessageSquare className="h-3 w-3 text-muted-foreground" />
+ </TooltipTrigger>
+ <TooltipContent>
+ <p className="max-w-xs">{doc.buyerSystemComment}</p>
+ </TooltipContent>
+ </Tooltip>
+ )}
+ </div>
+ )
+ },
+ size: 120,
+ },
{
accessorKey: "currentStageName",
header: ({ column }) => (
@@ -486,7 +560,7 @@ export function getDocumentStagesColumns({
label: "Delete Document",
icon: Trash2,
action: () => setRowAction({ row, type: "delete" }),
- show: true,
+ show: !doc.buyerSystemStatus, // null일 때만 true
className: "text-red-600 dark:text-red-400",
shortcut: "⌘⌫"
}
diff --git a/lib/vendor-document-list/plant/document-stages-expanded-content.tsx b/lib/vendor-document-list/plant/document-stages-expanded-content.tsx
index ca5e9c5b..72a804a8 100644
--- a/lib/vendor-document-list/plant/document-stages-expanded-content.tsx
+++ b/lib/vendor-document-list/plant/document-stages-expanded-content.tsx
@@ -2,7 +2,7 @@
"use client"
import React from "react"
-import { DocumentStagesOnlyView } from "@/db/schema"
+import { StageDocumentsView } from "@/db/schema"
import { Badge } from "@/components/ui/badge"
import { Button } from "@/components/ui/button"
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
@@ -40,7 +40,7 @@ import { toast } from "sonner"
import { updateStage } from "./document-stages-service"
interface DocumentStagesExpandedContentProps {
- document: DocumentStagesOnlyView
+ document: StageDocumentsView
onEditStage: (stageId: number) => void
projectType: "ship" | "plant"
}
diff --git a/lib/vendor-document-list/plant/document-stages-service.ts b/lib/vendor-document-list/plant/document-stages-service.ts
index 57f17bae..30a235c3 100644
--- a/lib/vendor-document-list/plant/document-stages-service.ts
+++ b/lib/vendor-document-list/plant/document-stages-service.ts
@@ -4,7 +4,7 @@
import { revalidatePath, revalidateTag } from "next/cache"
import { redirect } from "next/navigation"
import db from "@/db/db"
-import { codeGroups, comboBoxSettings, contracts, documentClassOptions, documentClasses, documentNumberTypeConfigs, documentNumberTypes, documentStagesOnlyView, documents, issueStages, stageDocuments, stageDocumentsView, stageIssueStages } from "@/db/schema"
+import {stageSubmissionView, codeGroups, comboBoxSettings, contracts, documentClassOptions, documentClasses, documentNumberTypeConfigs, documentNumberTypes, documentStagesOnlyView, documents, issueStages, stageDocuments, stageDocumentsView, stageIssueStages } from "@/db/schema"
import { and, eq, asc, desc, sql, inArray, max, ne, or, ilike } from "drizzle-orm"
import {
createDocumentSchema,
@@ -32,6 +32,7 @@ import { GetEnhancedDocumentsSchema, GetDocumentsSchema } from "../enhanced-docu
import { countDocumentStagesOnly, selectDocumentStagesOnly } from "../repository"
import { getServerSession } from "next-auth"
import { authOptions } from "@/app/api/auth/[...nextauth]/route"
+import { ShiBuyerSystemAPI } from "./shi-buyer-system-api"
interface UpdateDocumentData {
documentId: number
@@ -810,7 +811,7 @@ export async function getDocumentClassOptions(documentClassId: number) {
eq(documentClassOptions.isActive, true)
)
)
- // .orderBy(asc(documentClassOptions.sortOrder))
+ .orderBy(asc(documentClassOptions.sdq))
return { success: true, data: options }
} catch (error) {
@@ -920,6 +921,8 @@ export async function createDocument(data: CreateDocumentData) {
},
})
+ console.log(contract,"contract")
+
if (!contract) {
return { success: false, error: "유효하지 않은 계약(ID)입니다." }
}
@@ -1053,7 +1056,7 @@ export async function getDocumentStagesOnly(
finalWhere = and(
advancedWhere,
globalWhere,
- eq(documentStagesOnlyView.contractId, contractId)
+ eq(stageDocumentsView.contractId, contractId)
)
}
@@ -1066,7 +1069,7 @@ export async function getDocumentStagesOnly(
? desc(stageDocumentsView[item.id])
: asc(stageDocumentsView[item.id])
)
- : [desc(documentStagesOnlyView.createdAt)]
+ : [desc(stageDocumentsView.createdAt)]
// 트랜잭션 실행
@@ -1183,3 +1186,115 @@ export async function getDocumentsByStageStats(contractId: number) {
return []
}
}
+
+
+export async function sendDocumentsToSHI(contractId: number) {
+ try {
+ const api = new ShiBuyerSystemAPI()
+ const result = await api.sendToSHI(contractId)
+
+ // 캐시 무효화
+ revalidatePath(`/partners/document-list-only/${contractId}`)
+
+ return result
+ } catch (error) {
+ console.error("SHI 전송 실패:", error)
+ return {
+ success: false,
+ message: error instanceof Error ? error.message : "전송 중 오류가 발생했습니다."
+ }
+ }
+}
+
+export async function pullDocumentStatusFromSHI(
+ contractId: number,
+) {
+ try {
+ const api = new ShiBuyerSystemAPI()
+ const result = await api.pullDocumentStatus(contractId)
+
+ // 캐시 무효화
+ revalidatePath(`/partners/document-list-only/${contractId}`)
+
+ return result
+ } catch (error) {
+ console.error("문서 상태 풀링 실패:", error)
+ return {
+ success: false,
+ message: error instanceof Error ? error.message : "상태 가져오기 중 오류가 발생했습니다."
+ }
+ }
+}
+
+
+interface FileValidation {
+ projectId: number
+ docNumber: string
+ stageName: string
+ revision: string
+}
+
+interface ValidationResult {
+ projectId: number
+ docNumber: string
+ stageName: string
+ matched?: {
+ documentId: number
+ stageId: number
+ documentTitle: string
+ currentRevision?: number
+ }
+}
+
+export async function validateFiles(files: FileValidation[]): Promise<ValidationResult[]> {
+ const session = await getServerSession(authOptions)
+ if (!session?.user?.companyId) {
+ throw new Error("Unauthorized")
+ }
+
+ const vendorId = session.user.companyId
+ const results: ValidationResult[] = []
+
+ for (const file of files) {
+ // stageSubmissionView에서 매칭되는 레코드 찾기
+ const match = await db
+ .select({
+ documentId: stageSubmissionView.documentId,
+ stageId: stageSubmissionView.stageId,
+ documentTitle: stageSubmissionView.documentTitle,
+ latestRevisionNumber: stageSubmissionView.latestRevisionNumber,
+ })
+ .from(stageSubmissionView)
+ .where(
+ and(
+ eq(stageSubmissionView.vendorId, vendorId),
+ eq(stageSubmissionView.projectId, file.projectId),
+ eq(stageSubmissionView.docNumber, file.docNumber),
+ eq(stageSubmissionView.stageName, file.stageName)
+ )
+ )
+ .limit(1)
+
+ if (match.length > 0) {
+ results.push({
+ projectId: file.projectId,
+ docNumber: file.docNumber,
+ stageName: file.stageName,
+ matched: {
+ documentId: match[0].documentId,
+ stageId: match[0].stageId!,
+ documentTitle: match[0].documentTitle,
+ currentRevision: match[0].latestRevisionNumber || 0,
+ }
+ })
+ } else {
+ results.push({
+ projectId: file.projectId,
+ docNumber: file.docNumber,
+ stageName: file.stageName,
+ })
+ }
+ }
+
+ return results
+} \ No newline at end of file
diff --git a/lib/vendor-document-list/plant/document-stages-table.tsx b/lib/vendor-document-list/plant/document-stages-table.tsx
index 3d2ddafd..50d54a92 100644
--- a/lib/vendor-document-list/plant/document-stages-table.tsx
+++ b/lib/vendor-document-list/plant/document-stages-table.tsx
@@ -9,7 +9,7 @@ import type {
import { useDataTable } from "@/hooks/use-data-table"
import { getDocumentStagesOnly } from "./document-stages-service"
-import type { DocumentStagesOnlyView } from "@/db/schema"
+import type { StageDocumentsView } from "@/db/schema"
import { DataTableAdvancedToolbar } from "@/components/data-table/data-table-advanced-toolbar"
import { Badge } from "@/components/ui/badge"
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
@@ -51,17 +51,17 @@ export function DocumentStagesTable({
const { data: session } = useSession()
-
+
// URL에서 언어 파라미터 가져오기
const params = useParams()
const lng = (params?.lng as string) || 'ko'
const { t } = useTranslation(lng, 'document')
- // 세션에서 도메인을 가져오기
- const currentDomain = session?.user?.domain as "evcp" | "partners"
+ // 세션에서 도메인을 가져오기
+ const currentDomain = session?.user?.domain as "evcp" | "partners"
// 상태 관리
- const [rowAction, setRowAction] = React.useState<DataTableRowAction<DocumentStagesOnlyView> | null>(null)
+ const [rowAction, setRowAction] = React.useState<DataTableRowAction<StageDocumentsView> | null>(null)
const [expandedRows, setExpandedRows] = React.useState<Set<string>>(new Set())
const [quickFilter, setQuickFilter] = React.useState<'all' | 'overdue' | 'due_soon' | 'in_progress' | 'high_priority'>('all')
@@ -72,7 +72,7 @@ export function DocumentStagesTable({
const [excelImportOpen, setExcelImportOpen] = React.useState(false)
// 선택된 항목들
- const [selectedDocument, setSelectedDocument] = React.useState<DocumentStagesOnlyView | null>(null)
+ const [selectedDocument, setSelectedDocument] = React.useState<StageDocumentsView | null>(null)
const [selectedStageId, setSelectedStageId] = React.useState<number | null>(null)
// 컬럼 정의
@@ -116,7 +116,7 @@ export function DocumentStagesTable({
const stats = React.useMemo(() => {
console.log('DocumentStagesTable - data:', data)
console.log('DocumentStagesTable - data length:', data?.length)
-
+
const totalDocs = data?.length || 0
const overdue = data?.filter(doc => doc.isOverdue)?.length || 0
const dueSoon = data?.filter(doc =>
@@ -138,7 +138,7 @@ export function DocumentStagesTable({
highPriority,
avgProgress
}
-
+
console.log('DocumentStagesTable - stats:', result)
return result
}, [data])
@@ -201,10 +201,10 @@ export function DocumentStagesTable({
}
// 필터 필드 정의
- const filterFields: DataTableFilterField<DocumentStagesOnlyView>[] = [
+ const filterFields: DataTableFilterField<StageDocumentsView>[] = [
]
- const advancedFilterFields: DataTableAdvancedFilterField<DocumentStagesOnlyView>[] = [
+ const advancedFilterFields: DataTableAdvancedFilterField<StageDocumentsView>[] = [
{
id: "docNumber",
label: "문서번호",
diff --git a/lib/vendor-document-list/plant/excel-import-export.ts b/lib/vendor-document-list/plant/excel-import-export.ts
index 3ddb7195..c1409205 100644
--- a/lib/vendor-document-list/plant/excel-import-export.ts
+++ b/lib/vendor-document-list/plant/excel-import-export.ts
@@ -10,7 +10,7 @@ import {
type ExcelImportResult,
type CreateDocumentInput
} from './document-stage-validations'
-import { DocumentStagesOnlyView } from '@/db/schema'
+import { StageDocumentsView } from '@/db/schema'
// =============================================================================
// 1. 엑셀 템플릿 생성 및 다운로드
@@ -510,7 +510,7 @@ function formatExcelDate(value: any): string | undefined {
// 문서 데이터를 엑셀로 익스포트
export function exportDocumentsToExcel(
- documents: DocumentStagesOnlyView[],
+ documents: StageDocumentsView[],
projectType: "ship" | "plant"
) {
const headers = [
@@ -609,7 +609,7 @@ export function exportDocumentsToExcel(
}
// 스테이지 상세 데이터를 엑셀로 익스포트
-export function exportStageDetailsToExcel(documents: DocumentStagesOnlyView[]) {
+export function exportStageDetailsToExcel(documents: StageDocumentsView[]) {
const headers = [
"문서번호",
"문서명",
diff --git a/lib/vendor-document-list/plant/shi-buyer-system-api.ts b/lib/vendor-document-list/plant/shi-buyer-system-api.ts
new file mode 100644
index 00000000..1f15efa6
--- /dev/null
+++ b/lib/vendor-document-list/plant/shi-buyer-system-api.ts
@@ -0,0 +1,874 @@
+// app/lib/shi-buyer-system-api.ts
+import db from "@/db/db"
+import { stageDocuments, stageIssueStages, contracts, vendors, projects, stageSubmissions, stageSubmissionAttachments } from "@/db/schema"
+import { eq, and, sql, ne } from "drizzle-orm"
+import fs from 'fs/promises'
+import path from 'path'
+
+interface ShiDocumentInfo {
+ PROJ_NO: string
+ SHI_DOC_NO: string
+ CATEGORY: string
+ RESPONSIBLE_CD: string
+ RESPONSIBLE: string
+ VNDR_CD: string
+ VNDR_NM: string
+ DSN_SKL: string
+ MIFP_CD: string
+ MIFP_NM: string
+ CG_EMPNO1: string
+ CG_EMPNM1: string
+ OWN_DOC_NO: string
+ DSC: string
+ DOC_CLASS: string
+ COMMENT: string
+ STATUS: string
+ CRTER: string
+ CRTE_DTM: string
+ CHGR: string
+ CHG_DTM: string
+}
+
+interface ShiScheduleInfo {
+ PROJ_NO: string
+ SHI_DOC_NO: string
+ DDPKIND: string
+ SCHEDULE_TYPE: string
+ BASELINE1: string | null
+ REVISED1: string | null
+ FORECAST1: string | null
+ ACTUAL1: string | null
+ BASELINE2: string | null
+ REVISED2: string | null
+ FORECAST2: string | null
+ ACTUAL2: string | null
+ CRTER: string
+ CRTE_DTM: string
+ CHGR: string
+ CHG_DTM: string
+}
+
+// SHI API 응답 타입
+interface ShiDocumentResponse {
+ PROJ_NO: string
+ SHI_DOC_NO: string
+ STATUS: string
+ COMMENT: string | null
+ CATEGORY?: string
+ RESPONSIBLE_CD?: string
+ RESPONSIBLE?: string
+ VNDR_CD?: string
+ VNDR_NM?: string
+ DSN_SKL?: string
+ MIFP_CD?: string
+ MIFP_NM?: string
+ CG_EMPNO1?: string
+ CG_EMPNM1?: string
+ OWN_DOC_NO?: string
+ DSC?: string
+ DOC_CLASS?: string
+ CRTER?: string
+ CRTE_DTM?: string
+ CHGR?: string
+ CHG_DTM?: string
+}
+
+interface ShiApiResponse {
+ GetDwgInfoResult: ShiDocumentResponse[]
+}
+
+// InBox 파일 정보 인터페이스 추가
+interface InBoxFileInfo {
+ PROJ_NO: string
+ SHI_DOC_NO: string
+ STAGE_NAME: string
+ REVISION_NO: string
+ VNDR_CD: string
+ VNDR_NM: string
+ FILE_NAME: string
+ FILE_SIZE: number
+ CONTENT_TYPE: string
+ UPLOAD_DATE: string
+ UPLOADED_BY: string
+ STATUS: string
+ COMMENT: string
+}
+
+// SaveInBoxList API 응답 인터페이스
+interface SaveInBoxListResponse {
+ SaveInBoxListResult: {
+ success: boolean
+ message: string
+ processedCount?: number
+ files?: Array<{
+ fileName: string
+ networkPath: string
+ status: string
+ }>
+ }
+}
+
+export class ShiBuyerSystemAPI {
+ private baseUrl = process.env.SWP_BASE_URL || 'http://60.100.99.217/DDP/Services/VNDRService.svc'
+ private ddcUrl = process.env.DDC_BASE_URL || 'http://60.100.99.217/DDC/Services/WebService.svc'
+ private localStoragePath = process.env.NAS_PATH || './uploads'
+
+ async sendToSHI(contractId: number) {
+ try {
+ // 1. 전송할 문서 조회
+ const documents = await this.getDocumentsToSend(contractId)
+
+ if (documents.length === 0) {
+ return { success: false, message: "전송할 문서가 없습니다." }
+ }
+
+ // 2. 도서 정보 전송
+ await this.sendDocumentInfo(documents)
+
+ // 3. 스케줄 정보 전송
+ await this.sendScheduleInfo(documents)
+
+ // 4. 동기화 상태 업데이트
+ await this.updateSyncStatus(documents.map(d => d.documentId))
+
+ return {
+ success: true,
+ message: `${documents.length}개 문서가 성공적으로 전송되었습니다.`,
+ count: documents.length
+ }
+ } catch (error) {
+ console.error("SHI 전송 오류:", error)
+
+ // 에러 시 동기화 상태 업데이트
+ await this.updateSyncError(
+ contractId,
+ error instanceof Error ? error.message : "알 수 없는 오류"
+ )
+
+ throw error
+ }
+ }
+
+ private async getDocumentsToSend(contractId: number) {
+ const result = await db
+ .select({
+ documentId: stageDocuments.id,
+ docNumber: stageDocuments.docNumber,
+ vendorDocNumber: stageDocuments.vendorDocNumber,
+ title: stageDocuments.title,
+ status: stageDocuments.status,
+ projectCode: sql<string>`(SELECT code FROM projects WHERE id = ${stageDocuments.projectId})`,
+ vendorCode: sql<string>`(SELECT vendor_code FROM vendors WHERE id = ${stageDocuments.vendorId})`,
+ vendorName: sql<string>`(SELECT vendor_name FROM vendors WHERE id = ${stageDocuments.vendorId})`,
+ stages: sql<any[]>`
+ COALESCE(
+ (SELECT json_agg(row_to_json(s.*))
+ FROM stage_issue_stages s
+ WHERE s.document_id = ${stageDocuments.id}
+ ORDER BY s.stage_order),
+ '[]'::json
+ )
+ `
+ })
+ .from(stageDocuments)
+ .where(
+ and(
+ eq(stageDocuments.contractId, contractId),
+ eq(stageDocuments.status, 'ACTIVE'),
+ ne(stageDocuments.buyerSystemStatus, "승인(DC)")
+ )
+ )
+
+ return result
+ }
+
+ private async sendDocumentInfo(documents: any[]) {
+ const shiDocuments: ShiDocumentInfo[] = documents.map(doc => ({
+ PROJ_NO: doc.projectCode,
+ SHI_DOC_NO: doc.docNumber,
+ CATEGORY: "SHIP",
+ RESPONSIBLE_CD: "EVCP",
+ RESPONSIBLE: "eVCP System",
+ VNDR_CD: doc.vendorCode || "",
+ VNDR_NM: doc.vendorName || "",
+ DSN_SKL: "B3",
+ MIFP_CD: "",
+ MIFP_NM: "",
+ CG_EMPNO1: "",
+ CG_EMPNM1: "",
+ OWN_DOC_NO: doc.vendorDocNumber || doc.docNumber,
+ DSC: doc.title,
+ DOC_CLASS: "B3",
+ COMMENT: "",
+ STATUS: "ACTIVE",
+ CRTER: "EVCP_SYSTEM",
+ CRTE_DTM: new Date().toISOString(),
+ CHGR: "EVCP_SYSTEM",
+ CHG_DTM: new Date().toISOString()
+ }))
+
+ const response = await fetch(`${this.baseUrl}/SetDwgInfo`, {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json'
+ },
+ body: JSON.stringify(shiDocuments)
+ })
+
+ if (!response.ok) {
+ const errorText = await response.text()
+ throw new Error(`도서 정보 전송 실패: ${response.statusText} - ${errorText}`)
+ }
+
+ return response.json()
+ }
+
+ private async sendScheduleInfo(documents: any[]) {
+ const schedules: ShiScheduleInfo[] = []
+
+ for (const doc of documents) {
+ for (const stage of doc.stages) {
+ if (stage.plan_date) {
+ schedules.push({
+ PROJ_NO: doc.projectCode,
+ SHI_DOC_NO: doc.docNumber,
+ DDPKIND: "V",
+ SCHEDULE_TYPE: stage.stage_name,
+ BASELINE1: stage.plan_date ? new Date(stage.plan_date).toISOString() : null,
+ REVISED1: null,
+ FORECAST1: null,
+ ACTUAL1: stage.actual_date ? new Date(stage.actual_date).toISOString() : null,
+ BASELINE2: null,
+ REVISED2: null,
+ FORECAST2: null,
+ ACTUAL2: null,
+ CRTER: "EVCP_SYSTEM",
+ CRTE_DTM: new Date().toISOString(),
+ CHGR: "EVCP_SYSTEM",
+ CHG_DTM: new Date().toISOString()
+ })
+ }
+ }
+ }
+
+ if (schedules.length === 0) {
+ console.log("전송할 스케줄 정보가 없습니다.")
+ return
+ }
+
+ const response = await fetch(`${this.baseUrl}/SetScheduleInfo`, {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json'
+ },
+ body: JSON.stringify(schedules)
+ })
+
+ if (!response.ok) {
+ const errorText = await response.text()
+ throw new Error(`스케줄 정보 전송 실패: ${response.statusText} - ${errorText}`)
+ }
+
+ return response.json()
+ }
+
+ private async updateSyncStatus(documentIds: number[]) {
+ if (documentIds.length === 0) return
+
+ await db
+ .update(stageDocuments)
+ .set({
+ syncStatus: 'synced',
+ lastSyncedAt: new Date(),
+ syncError: null,
+ syncVersion: sql`sync_version + 1`,
+ lastModifiedBy: 'EVCP'
+ })
+ .where(sql`id = ANY(${documentIds})`)
+ }
+
+ private async updateSyncError(contractId: number, errorMessage: string) {
+ await db
+ .update(stageDocuments)
+ .set({
+ syncStatus: 'error',
+ syncError: errorMessage,
+ lastModifiedBy: 'EVCP'
+ })
+ .where(
+ and(
+ eq(stageDocuments.contractId, contractId),
+ eq(stageDocuments.status, 'ACTIVE')
+ )
+ )
+ }
+
+ async pullDocumentStatus(contractId: number) {
+ try {
+ const contract = await db.query.contracts.findFirst({
+ where: eq(contracts.id, contractId),
+ });
+
+ if (!contract) {
+ throw new Error(`계약을 찾을 수 없습니다: ${contractId}`)
+ }
+
+ const project = await db.query.projects.findFirst({
+ where: eq(projects.id, contract.projectId),
+ });
+
+ if (!project) {
+ throw new Error(`프로젝트를 찾을 수 없습니다: ${contract.projectId}`)
+ }
+
+ const vendor = await db.query.vendors.findFirst({
+ where: eq(vendors.id, contract.vendorId),
+ });
+
+ if (!vendor) {
+ throw new Error(`벤더를 찾을 수 없습니다: ${contract.vendorId}`)
+ }
+
+ const shiDocuments = await this.fetchDocumentsFromSHI(project.code, {
+ VNDR_CD: vendor.vendorCode
+ })
+
+ if (!shiDocuments || shiDocuments.length === 0) {
+ return {
+ success: true,
+ message: "동기화할 문서가 없습니다.",
+ updatedCount: 0,
+ documents: []
+ }
+ }
+
+ const updateResults = await this.updateLocalDocuments(project.code, shiDocuments)
+
+ return {
+ success: true,
+ message: `${updateResults.updatedCount}개 문서의 상태가 업데이트되었습니다.`,
+ updatedCount: updateResults.updatedCount,
+ newCount: updateResults.newCount,
+ documents: updateResults.documents
+ }
+ } catch (error) {
+ console.error("문서 상태 풀링 오류:", error)
+ throw error
+ }
+ }
+
+ private async fetchDocumentsFromSHI(
+ projectCode: string,
+ filters?: {
+ SHI_DOC_NO?: string
+ CATEGORY?: string
+ VNDR_CD?: string
+ RESPONSIBLE_CD?: string
+ STATUS?: string
+ DOC_CLASS?: string
+ CRTE_DTM_FROM?: string
+ CRTE_DTM_TO?: string
+ CHG_DTM_FROM?: string
+ CHG_DTM_TO?: string
+ }
+ ): Promise<ShiDocumentResponse[]> {
+ const params = new URLSearchParams({ PROJ_NO: projectCode })
+
+ if (filters) {
+ Object.entries(filters).forEach(([key, value]) => {
+ if (value) params.append(key, value)
+ })
+ }
+
+ const url = `${this.baseUrl}/GetDwgInfo?${params.toString()}`
+
+ const response = await fetch(url, {
+ method: 'GET',
+ headers: {
+ 'Accept': 'application/json'
+ }
+ })
+
+ if (!response.ok) {
+ throw new Error(`문서 조회 실패: ${response.statusText}`)
+ }
+
+ const data: ShiApiResponse = await response.json()
+
+ return data.GetDwgInfoResult || []
+ }
+
+ private async updateLocalDocuments(
+ projectCode: string,
+ shiDocuments: ShiDocumentResponse[]
+ ) {
+ let updatedCount = 0
+ let newCount = 0
+ const updatedDocuments: any[] = []
+
+ const project = await db.query.projects.findFirst({
+ where: eq(projects.code, projectCode)
+ })
+
+ if (!project) {
+ throw new Error(`프로젝트를 찾을 수 없습니다: ${projectCode}`)
+ }
+
+ for (const shiDoc of shiDocuments) {
+ const localDoc = await db.query.stageDocuments.findFirst({
+ where: and(
+ eq(stageDocuments.projectId, project.id),
+ eq(stageDocuments.docNumber, shiDoc.SHI_DOC_NO)
+ )
+ })
+
+ if (localDoc) {
+ if (
+ localDoc.buyerSystemStatus !== shiDoc.STATUS ||
+ localDoc.buyerSystemComment !== shiDoc.COMMENT
+ ) {
+ await db
+ .update(stageDocuments)
+ .set({
+ buyerSystemStatus: shiDoc.STATUS,
+ buyerSystemComment: shiDoc.COMMENT,
+ lastSyncedAt: new Date(),
+ syncStatus: 'synced',
+ syncError: null,
+ lastModifiedBy: 'BUYER_SYSTEM',
+ syncVersion: sql`sync_version + 1`
+ })
+ .where(eq(stageDocuments.id, localDoc.id))
+
+ updatedCount++
+ updatedDocuments.push({
+ docNumber: shiDoc.SHI_DOC_NO,
+ title: shiDoc.DSC || localDoc.title,
+ status: shiDoc.STATUS,
+ comment: shiDoc.COMMENT,
+ action: 'updated'
+ })
+ }
+ } else {
+ console.log(`SHI에만 존재하는 문서: ${shiDoc.SHI_DOC_NO}`)
+ newCount++
+ updatedDocuments.push({
+ docNumber: shiDoc.SHI_DOC_NO,
+ title: shiDoc.DSC || 'N/A',
+ status: shiDoc.STATUS,
+ comment: shiDoc.COMMENT,
+ action: 'new_in_shi'
+ })
+ }
+ }
+
+ return {
+ updatedCount,
+ newCount,
+ documents: updatedDocuments
+ }
+ }
+
+ async getSyncStatus(contractId: number) {
+ const documents = await db
+ .select({
+ docNumber: stageDocuments.docNumber,
+ title: stageDocuments.title,
+ syncStatus: stageDocuments.syncStatus,
+ lastSyncedAt: stageDocuments.lastSyncedAt,
+ syncError: stageDocuments.syncError,
+ buyerSystemStatus: stageDocuments.buyerSystemStatus,
+ buyerSystemComment: stageDocuments.buyerSystemComment
+ })
+ .from(stageDocuments)
+ .where(eq(stageDocuments.contractId, contractId))
+
+ return documents
+ }
+
+ /**
+ * 스테이지 제출 건들의 파일을 SHI 구매자 시스템으로 동기화
+ * @param submissionIds 제출 ID 배열
+ */
+ async syncSubmissionsToSHI(submissionIds: number[]) {
+ const results = {
+ totalCount: submissionIds.length,
+ successCount: 0,
+ failedCount: 0,
+ details: [] as any[]
+ }
+
+ for (const submissionId of submissionIds) {
+ try {
+ const result = await this.syncSingleSubmission(submissionId)
+ if (result.success) {
+ results.successCount++
+ } else {
+ results.failedCount++
+ }
+ results.details.push(result)
+ } catch (error) {
+ results.failedCount++
+ results.details.push({
+ submissionId,
+ success: false,
+ error: error instanceof Error ? error.message : "Unknown error"
+ })
+ }
+ }
+
+ return results
+ }
+
+ /**
+ * 단일 제출 건 동기화
+ */
+ private async syncSingleSubmission(submissionId: number) {
+ try {
+ // 1. 제출 정보 조회 (프로젝트, 문서, 스테이지, 파일 정보 포함)
+ const submissionInfo = await this.getSubmissionFullInfo(submissionId)
+
+ if (!submissionInfo) {
+ throw new Error(`제출 정보를 찾을 수 없습니다: ${submissionId}`)
+ }
+
+ // 2. 동기화 시작 상태 업데이트
+ await this.updateSubmissionSyncStatus(submissionId, 'syncing')
+
+ // 3. 첨부파일들과 실제 파일 내용을 준비
+ const filesWithContent = await this.prepareFilesWithContent(submissionInfo)
+
+ if (filesWithContent.length === 0) {
+ await this.updateSubmissionSyncStatus(submissionId, 'synced', '전송할 파일이 없습니다')
+ return {
+ submissionId,
+ success: true,
+ message: "전송할 파일이 없습니다"
+ }
+ }
+
+ // 4. SaveInBoxList API 호출하여 네트워크 경로 받기
+ const response = await this.sendToInBox(filesWithContent)
+
+ // 5. 응답받은 네트워크 경로에 파일 저장
+ if (response.SaveInBoxListResult.success && response.SaveInBoxListResult.files) {
+ await this.saveFilesToNetworkPaths(filesWithContent, response.SaveInBoxListResult.files)
+
+ // 6. 동기화 결과 업데이트
+ await this.updateSubmissionSyncStatus(submissionId, 'synced', null, {
+ syncedFilesCount: filesWithContent.length,
+ buyerSystemStatus: 'SYNCED'
+ })
+
+ // 개별 파일 상태 업데이트
+ await this.updateAttachmentsSyncStatus(
+ submissionInfo.attachments.map(a => a.id),
+ 'synced'
+ )
+
+ return {
+ submissionId,
+ success: true,
+ message: response.SaveInBoxListResult.message,
+ syncedFiles: filesWithContent.length
+ }
+ } else {
+ throw new Error(response.SaveInBoxListResult.message)
+ }
+ } catch (error) {
+ await this.updateSubmissionSyncStatus(
+ submissionId,
+ 'failed',
+ error instanceof Error ? error.message : '알 수 없는 오류'
+ )
+
+ throw error
+ }
+ }
+
+ /**
+ * 제출 정보 조회 (관련 정보 포함)
+ */
+ private async getSubmissionFullInfo(submissionId: number) {
+ const result = await db
+ .select({
+ submission: stageSubmissions,
+ stage: stageIssueStages,
+ document: stageDocuments,
+ project: projects,
+ vendor: vendors
+ })
+ .from(stageSubmissions)
+ .innerJoin(stageIssueStages, eq(stageSubmissions.stageId, stageIssueStages.id))
+ .innerJoin(stageDocuments, eq(stageSubmissions.documentId, stageDocuments.id))
+ .innerJoin(projects, eq(stageDocuments.projectId, projects.id))
+ .leftJoin(vendors, eq(stageDocuments.vendorId, vendors.id))
+ .where(eq(stageSubmissions.id, submissionId))
+ .limit(1)
+
+ if (result.length === 0) return null
+
+ // 첨부파일 조회 - 파일 경로 포함
+ const attachments = await db
+ .select()
+ .from(stageSubmissionAttachments)
+ .where(
+ and(
+ eq(stageSubmissionAttachments.submissionId, submissionId),
+ eq(stageSubmissionAttachments.status, 'ACTIVE')
+ )
+ )
+
+ return {
+ ...result[0],
+ attachments
+ }
+ }
+
+ /**
+ * 파일 내용과 함께 InBox 파일 정보 준비
+ */
+ private async prepareFilesWithContent(submissionInfo: any): Promise<Array<InBoxFileInfo & { fileBuffer: Buffer, attachment: any }>> {
+ const filesWithContent: Array<InBoxFileInfo & { fileBuffer: Buffer, attachment: any }> = []
+
+ for (const attachment of submissionInfo.attachments) {
+ try {
+ // 파일 경로 결정 (storagePath 또는 storageUrl 사용)
+ const filePath = attachment.storagePath || attachment.storageUrl
+
+ if (!filePath) {
+ console.warn(`첨부파일 ${attachment.id}의 경로를 찾을 수 없습니다.`)
+ continue
+ }
+
+ // 전체 경로 생성
+ const fullPath = path.isAbsolute(filePath)
+ ? filePath
+ : path.join(this.localStoragePath, filePath)
+
+ // 파일 읽기
+ const fileBuffer = await fs.readFile(fullPath)
+
+ // 파일 정보 생성
+ const fileInfo: InBoxFileInfo & { fileBuffer: Buffer, attachment: any } = {
+ PROJ_NO: submissionInfo.project.code,
+ SHI_DOC_NO: submissionInfo.document.docNumber,
+ STAGE_NAME: submissionInfo.stage.stageName,
+ REVISION_NO: String(submissionInfo.submission.revisionNumber),
+ VNDR_CD: submissionInfo.vendor?.vendorCode || '',
+ VNDR_NM: submissionInfo.vendor?.vendorName || '',
+ FILE_NAME: attachment.fileName,
+ FILE_SIZE: fileBuffer.length, // 실제 파일 크기 사용
+ CONTENT_TYPE: attachment.mimeType || 'application/octet-stream',
+ UPLOAD_DATE: new Date().toISOString(),
+ UPLOADED_BY: submissionInfo.submission.submittedBy,
+ STATUS: 'PENDING',
+ COMMENT: `Revision ${submissionInfo.submission.revisionNumber} - ${submissionInfo.stage.stageName}`,
+ fileBuffer: fileBuffer,
+ attachment: attachment
+ }
+
+ filesWithContent.push(fileInfo)
+ } catch (error) {
+ console.error(`파일 읽기 실패: ${attachment.fileName}`, error)
+ // 파일 읽기 실패 시 계속 진행
+ continue
+ }
+ }
+
+ return filesWithContent
+ }
+
+ /**
+ * SaveInBoxList API 호출 (파일 메타데이터만 전송)
+ */
+ private async sendToInBox(files: Array<InBoxFileInfo & { fileBuffer: Buffer }>): Promise<SaveInBoxListResponse> {
+ // fileBuffer를 제외한 메타데이터만 전송
+ const fileMetadata = files.map(({ fileBuffer, attachment, ...metadata }) => metadata)
+
+ const request = { files: fileMetadata }
+
+ const response = await fetch(`${this.ddcUrl}/SaveInBoxList`, {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json',
+ 'Accept': 'application/json'
+ },
+ body: JSON.stringify(request)
+ })
+
+ if (!response.ok) {
+ const errorText = await response.text()
+ throw new Error(`InBox 전송 실패: ${response.statusText} - ${errorText}`)
+ }
+
+ const data = await response.json()
+
+ // 응답 구조 확인 및 처리
+ if (!data.SaveInBoxListResult) {
+ return {
+ SaveInBoxListResult: {
+ success: true,
+ message: "전송 완료",
+ processedCount: files.length,
+ files: files.map(f => ({
+ fileName: f.FILE_NAME,
+ networkPath: `\\\\network\\share\\${f.PROJ_NO}\\${f.SHI_DOC_NO}\\${f.FILE_NAME}`,
+ status: 'READY'
+ }))
+ }
+ }
+ }
+
+ return data
+ }
+
+ /**
+ * 네트워크 경로에 파일 저장
+ */
+ private async saveFilesToNetworkPaths(
+ filesWithContent: Array<InBoxFileInfo & { fileBuffer: Buffer, attachment: any }>,
+ networkPathInfo: Array<{ fileName: string, networkPath: string, status: string }>
+ ) {
+ for (const fileInfo of filesWithContent) {
+ const pathInfo = networkPathInfo.find(p => p.fileName === fileInfo.FILE_NAME)
+
+ if (!pathInfo || !pathInfo.networkPath) {
+ console.error(`네트워크 경로를 찾을 수 없습니다: ${fileInfo.FILE_NAME}`)
+ continue
+ }
+
+ try {
+ // 네트워크 경로에 파일 저장
+ // Windows 네트워크 경로인 경우 처리
+ let targetPath = pathInfo.networkPath
+
+ // Windows 네트워크 경로를 Node.js가 이해할 수 있는 형식으로 변환
+ if (process.platform === 'win32' && targetPath.startsWith('\\\\')) {
+ // 그대로 사용
+ } else if (process.platform !== 'win32' && targetPath.startsWith('\\\\')) {
+ // Linux/Mac에서는 SMB 마운트 경로로 변환 필요
+ // 예: \\\\server\\share -> /mnt/server/share
+ targetPath = targetPath.replace(/\\\\/g, '/mnt/').replace(/\\/g, '/')
+ }
+
+ // 디렉토리 생성 (없는 경우)
+ const targetDir = path.dirname(targetPath)
+ await fs.mkdir(targetDir, { recursive: true })
+
+ // 파일 저장
+ await fs.writeFile(targetPath, fileInfo.fileBuffer)
+
+ console.log(`파일 저장 완료: ${fileInfo.FILE_NAME} -> ${targetPath}`)
+
+ // DB에 네트워크 경로 업데이트
+ await db
+ .update(stageSubmissionAttachments)
+ .set({
+ buyerSystemUrl: pathInfo.networkPath,
+ buyerSystemStatus: 'UPLOADED',
+ lastModifiedBy: 'EVCP'
+ })
+ .where(eq(stageSubmissionAttachments.id, fileInfo.attachment.id))
+
+ } catch (error) {
+ console.error(`파일 저장 실패: ${fileInfo.FILE_NAME}`, error)
+ // 개별 파일 실패는 전체 프로세스를 중단하지 않음
+ }
+ }
+ }
+
+ /**
+ * 제출 동기화 상태 업데이트
+ */
+ private async updateSubmissionSyncStatus(
+ submissionId: number,
+ status: string,
+ error?: string | null,
+ additionalData?: any
+ ) {
+ const updateData: any = {
+ syncStatus: status,
+ lastSyncedAt: new Date(),
+ syncError: error,
+ lastModifiedBy: 'EVCP',
+ ...additionalData
+ }
+
+ if (status === 'failed') {
+ updateData.syncRetryCount = sql`sync_retry_count + 1`
+ updateData.nextRetryAt = new Date(Date.now() + 30 * 60 * 1000) // 30분 후 재시도
+ }
+
+ await db
+ .update(stageSubmissions)
+ .set(updateData)
+ .where(eq(stageSubmissions.id, submissionId))
+ }
+
+ /**
+ * 첨부파일 동기화 상태 업데이트
+ */
+ private async updateAttachmentsSyncStatus(
+ attachmentIds: number[],
+ status: string
+ ) {
+ if (attachmentIds.length === 0) return
+
+ await db
+ .update(stageSubmissionAttachments)
+ .set({
+ syncStatus: status,
+ syncCompletedAt: status === 'synced' ? new Date() : null,
+ buyerSystemStatus: status === 'synced' ? 'UPLOADED' : 'PENDING',
+ lastModifiedBy: 'EVCP'
+ })
+ .where(sql`id = ANY(${attachmentIds})`)
+ }
+
+ /**
+ * 동기화 재시도 (실패한 건들)
+ */
+ async retrySyncFailedSubmissions(contractId?: number) {
+ const conditions = [
+ eq(stageSubmissions.syncStatus, 'failed'),
+ sql`next_retry_at <= NOW()`
+ ]
+
+ if (contractId) {
+ const documentIds = await db
+ .select({ id: stageDocuments.id })
+ .from(stageDocuments)
+ .where(eq(stageDocuments.contractId, contractId))
+
+ if (documentIds.length > 0) {
+ conditions.push(
+ sql`document_id = ANY(${documentIds.map(d => d.id)})`
+ )
+ }
+ }
+
+ const failedSubmissions = await db
+ .select({ id: stageSubmissions.id })
+ .from(stageSubmissions)
+ .where(and(...conditions))
+ .limit(10) // 한 번에 최대 10개씩 재시도
+
+ if (failedSubmissions.length === 0) {
+ return {
+ success: true,
+ message: "재시도할 제출 건이 없습니다.",
+ retryCount: 0
+ }
+ }
+
+ const submissionIds = failedSubmissions.map(s => s.id)
+ const results = await this.syncSubmissionsToSHI(submissionIds)
+
+ return {
+ success: true,
+ message: `${results.successCount}/${results.totalCount}개 제출 건 재시도 완료`,
+ ...results
+ }
+ }
+} \ No newline at end of file
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