From 8b777900a2908efebd767493f77ee3ad489b49a7 Mon Sep 17 00:00:00 2001
From: joonhoekim <26rote@gmail.com>
Date: Fri, 24 Oct 2025 16:46:30 +0900
Subject: (김준회) fix: expand 핸들러의 event bubbling 문제 처리, 파일업로드
 버튼추가
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

---
 lib/swp/table/swp-table-columns.tsx | 2 --
 1 file changed, 2 deletions(-)

(limited to 'lib/swp/table/swp-table-columns.tsx')

diff --git a/lib/swp/table/swp-table-columns.tsx b/lib/swp/table/swp-table-columns.tsx
index dd605453..f79a7441 100644
--- a/lib/swp/table/swp-table-columns.tsx
+++ b/lib/swp/table/swp-table-columns.tsx
@@ -17,7 +17,6 @@ export const swpDocumentColumns: ColumnDef<SwpDocumentWithStats>[] = [
         <Button
           variant="ghost"
           size="sm"
-          onClick={row.getToggleExpandedHandler()}
           className="h-8 w-8 p-0"
         >
           {row.getIsExpanded() ? (
@@ -182,7 +181,6 @@ export const swpRevisionColumns: ColumnDef<RevisionRow>[] = [
         <Button
           variant="ghost"
           size="sm"
-          onClick={row.getToggleExpandedHandler()}
           className="h-8 w-8 p-0 ml-8"
         >
           {row.getIsExpanded() ? (
-- 
cgit v1.2.3


From 39fc95095ac4b99186294f21fe6d8ac0cfab1f6e Mon Sep 17 00:00:00 2001
From: joonhoekim <26rote@gmail.com>
Date: Fri, 24 Oct 2025 18:55:27 +0900
Subject: (김준회) 리비전, 파일 조회는 dialog 로 분리(옥프로 요청)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

---
 lib/swp/table/swp-table-columns.tsx |  32 ++--
 lib/swp/table/swp-table.tsx         | 300 +++++++++++++++++++++++-------------
 2 files changed, 220 insertions(+), 112 deletions(-)

(limited to 'lib/swp/table/swp-table-columns.tsx')

diff --git a/lib/swp/table/swp-table-columns.tsx b/lib/swp/table/swp-table-columns.tsx
index f79a7441..b18e2b27 100644
--- a/lib/swp/table/swp-table-columns.tsx
+++ b/lib/swp/table/swp-table-columns.tsx
@@ -3,7 +3,7 @@
 import { ColumnDef } from "@tanstack/react-table";
 import { Badge } from "@/components/ui/badge";
 import { Button } from "@/components/ui/button";
-import { ChevronDown, ChevronRight, FileIcon } from "lucide-react";
+import { ChevronDown, ChevronRight, FileIcon, Download } from "lucide-react";
 import { formatDistanceToNow } from "date-fns";
 import { ko } from "date-fns/locale";
 import type { SwpDocumentWithStats } from "../actions";
@@ -12,20 +12,16 @@ export const swpDocumentColumns: ColumnDef<SwpDocumentWithStats>[] = [
   {
     id: "expander",
     header: () => null,
-    cell: ({ row }) => {
-      return row.getCanExpand() ? (
+    cell: () => {
+      return (
         <Button
           variant="ghost"
           size="sm"
           className="h-8 w-8 p-0"
         >
-          {row.getIsExpanded() ? (
-            <ChevronDown className="h-4 w-4" />
-          ) : (
-            <ChevronRight className="h-4 w-4" />
-          )}
+          <ChevronRight className="h-4 w-4" />
         </Button>
-      ) : null;
+      );
     },
     size: 50,
   },
@@ -388,5 +384,23 @@ export const swpFileColumns: ColumnDef<FileRow>[] = [
     ),
     size: 100,
   },
+  {
+    id: "actions",
+    header: "작업",
+    cell: ({ row }) => (
+      <Button
+        variant="outline"
+        size="sm"
+        onClick={() => {
+          // TODO: 파일 다운로드 로직 구현
+          console.log("Download file:", row.original.FILE_NM);
+        }}
+      >
+        <Download className="h-4 w-4 mr-1" />
+        다운로드
+      </Button>
+    ),
+    size: 120,
+  },
 ];
 
diff --git a/lib/swp/table/swp-table.tsx b/lib/swp/table/swp-table.tsx
index fb3a504e..8ae90bdd 100644
--- a/lib/swp/table/swp-table.tsx
+++ b/lib/swp/table/swp-table.tsx
@@ -17,6 +17,13 @@ import {
   TableRow,
 } from "@/components/ui/table";
 import { Button } from "@/components/ui/button";
+import {
+  Dialog,
+  DialogContent,
+  DialogHeader,
+  DialogTitle,
+  DialogDescription,
+} from "@/components/ui/dialog";
 import { Loader2 } from "lucide-react";
 import { swpDocumentColumns, swpRevisionColumns, swpFileColumns, type RevisionRow, type FileRow } from "./swp-table-columns";
 import { fetchDocumentRevisions, fetchRevisionFiles, type SwpDocumentWithStats } from "../actions";
@@ -43,6 +50,8 @@ export function SwpTable({
   const [fileData, setFileData] = useState<Record<number, FileRow[]>>({});
   const [loadingRevisions, setLoadingRevisions] = useState<Set<string>>(new Set());
   const [loadingFiles, setLoadingFiles] = useState<Set<number>>(new Set());
+  const [dialogOpen, setDialogOpen] = useState(false);
+  const [selectedDocument, setSelectedDocument] = useState<SwpDocumentWithStats | null>(null);
 
   const table = useReactTable({
     data: initialData,
@@ -104,10 +113,26 @@ export function SwpTable({
     }
   };
 
-  // 문서 행 확장 핸들러
-  const handleDocumentExpand = (docNo: string, isExpanded: boolean) => {
-    if (isExpanded) {
-      loadRevisions(docNo);
+  // 문서 클릭 핸들러 - Dialog 열기
+  const handleDocumentClick = async (document: SwpDocumentWithStats) => {
+    setSelectedDocument(document);
+    setDialogOpen(true);
+    
+    // 리비전 데이터 로드
+    if (!revisionData[document.DOC_NO]) {
+      await loadRevisions(document.DOC_NO);
+    }
+  };
+
+  // 모든 리비전의 파일을 로드
+  const loadAllFiles = async (docNo: string) => {
+    const revisions = revisionData[docNo];
+    if (!revisions) return;
+
+    for (const revision of revisions) {
+      if (!fileData[revision.id]) {
+        await loadFiles(revision.id);
+      }
     }
   };
 
@@ -145,10 +170,8 @@ export function SwpTable({
                       <TableCell key={cell.id}>
                         {cell.column.id === "expander" ? (
                           <div
-                            onClick={() => {
-                              row.toggleExpanded();
-                              handleDocumentExpand(row.original.DOC_NO, row.getIsExpanded());
-                            }}
+                            onClick={() => handleDocumentClick(row.original)}
+                            className="cursor-pointer"
                           >
                             {flexRender(
                               cell.column.columnDef.cell,
@@ -161,31 +184,6 @@ export function SwpTable({
                       </TableCell>
                     ))}
                   </TableRow>
-
-                  {/* 리비전 행들 (확장 시) */}
-                  {row.getIsExpanded() && (
-                    <TableRow>
-                      <TableCell colSpan={swpDocumentColumns.length} className="p-0 bg-muted/30">
-                        {loadingRevisions.has(row.original.DOC_NO) ? (
-                          <div className="flex items-center justify-center p-8">
-                            <Loader2 className="h-6 w-6 animate-spin" />
-                            <span className="ml-2">리비전 로딩 중...</span>
-                          </div>
-                        ) : revisionData[row.original.DOC_NO]?.length ? (
-                          <RevisionSubTable
-                            revisions={revisionData[row.original.DOC_NO]}
-                            fileData={fileData}
-                            loadingFiles={loadingFiles}
-                            onLoadFiles={loadFiles}
-                          />
-                        ) : (
-                          <div className="p-8 text-center text-muted-foreground">
-                            리비전 없음
-                          </div>
-                        )}
-                      </TableCell>
-                    </TableRow>
-                  )}
                 </React.Fragment>
               ))
             ) : (
@@ -227,28 +225,95 @@ export function SwpTable({
           </Button>
         </div>
       </div>
+
+      {/* 문서 상세 Dialog */}
+      <Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
+        <DialogContent className="max-w-6xl max-h-[90vh]">
+          <DialogHeader>
+            <DialogTitle>문서 상세</DialogTitle>
+            {selectedDocument && (
+              <DialogDescription>
+                {selectedDocument.DOC_NO} - {selectedDocument.DOC_TITLE}
+              </DialogDescription>
+            )}
+          </DialogHeader>
+
+          {selectedDocument && (
+            <div className="space-y-4 overflow-y-auto">
+              {/* 문서 정보 */}
+              <div className="grid grid-cols-1 md:grid-cols-4 gap-4 p-4 bg-muted/30 rounded-lg">
+                <div>
+                  <span className="text-sm font-semibold">프로젝트:</span>
+                  <div className="text-sm">{selectedDocument.PROJ_NO}</div>
+                  {selectedDocument.PROJ_NM && (
+                    <div className="text-xs text-muted-foreground">{selectedDocument.PROJ_NM}</div>
+                  )}
+                </div>
+                <div>
+                  <span className="text-sm font-semibold">패키지:</span>
+                  <div className="text-sm">{selectedDocument.PKG_NO || "-"}</div>
+                </div>
+                <div>
+                  <span className="text-sm font-semibold">업체:</span>
+                  <div className="text-sm">{selectedDocument.CPY_NM || "-"}</div>
+                  {selectedDocument.VNDR_CD && (
+                    <div className="text-xs text-muted-foreground">{selectedDocument.VNDR_CD}</div>
+                  )}
+                </div>
+                <div>
+                  <span className="text-sm font-semibold">최신 리비전:</span>
+                  <div className="text-sm">{selectedDocument.LTST_REV_NO || "-"}</div>
+                </div>
+              </div>
+
+              {/* 리비전 및 파일 목록 */}
+              {loadingRevisions.has(selectedDocument.DOC_NO) ? (
+                <div className="flex items-center justify-center p-8">
+                  <Loader2 className="h-6 w-6 animate-spin" />
+                  <span className="ml-2">리비전 로딩 중...</span>
+                </div>
+              ) : revisionData[selectedDocument.DOC_NO]?.length ? (
+                <DocumentDetailView
+                  revisions={revisionData[selectedDocument.DOC_NO]}
+                  fileData={fileData}
+                  loadingFiles={loadingFiles}
+                  onLoadFiles={loadFiles}
+                  onLoadAllFiles={() => loadAllFiles(selectedDocument.DOC_NO)}
+                />
+              ) : (
+                <div className="p-8 text-center text-muted-foreground">
+                  리비전 없음
+                </div>
+              )}
+            </div>
+          )}
+        </DialogContent>
+      </Dialog>
     </div>
   );
 }
 
 // ============================================================================
-// 리비전 서브 테이블
+// 문서 상세 뷰 (Dialog용)
 // ============================================================================
 
-interface RevisionSubTableProps {
+interface DocumentDetailViewProps {
   revisions: RevisionRow[];
   fileData: Record<number, FileRow[]>;
   loadingFiles: Set<number>;
   onLoadFiles: (revisionId: number) => void;
+  onLoadAllFiles: () => void;
 }
 
-function RevisionSubTable({
+function DocumentDetailView({
   revisions,
   fileData,
   loadingFiles,
   onLoadFiles,
-}: RevisionSubTableProps) {
+  onLoadAllFiles,
+}: DocumentDetailViewProps) {
   const [expandedRevisions, setExpandedRevisions] = useState<ExpandedState>({});
+  const [allExpanded, setAllExpanded] = useState(false);
 
   const revisionTable = useReactTable({
     data: revisions,
@@ -262,80 +327,109 @@ function RevisionSubTable({
     getRowCanExpand: () => true,
   });
 
-  const handleRevisionExpand = (revisionId: number, isExpanded: boolean) => {
-    if (isExpanded) {
-      onLoadFiles(revisionId);
+  const handleExpandAll = () => {
+    if (allExpanded) {
+      setExpandedRevisions({});
+    } else {
+      const expanded: ExpandedState = {};
+      revisions.forEach((_, index) => {
+        expanded[index] = true;
+      });
+      setExpandedRevisions(expanded);
+      onLoadAllFiles();
     }
+    setAllExpanded(!allExpanded);
+  };
+
+  const handleRevisionExpand = (revisionId: number) => {
+    onLoadFiles(revisionId);
   };
 
   return (
-    <div className="border-l-4 border-blue-200">
-      <Table>
-        <TableHeader>
-          {revisionTable.getHeaderGroups().map((headerGroup) => (
-            <TableRow key={headerGroup.id} className="bg-muted/50">
-              {headerGroup.headers.map((header) => (
-                <TableHead key={header.id} className="font-semibold">
-                  {header.isPlaceholder
-                    ? null
-                    : flexRender(
-                        header.column.columnDef.header,
-                        header.getContext()
-                      )}
-                </TableHead>
-              ))}
-            </TableRow>
-          ))}
-        </TableHeader>
-        <TableBody>
-          {revisionTable.getRowModel().rows.map((row) => (
-            <React.Fragment key={row.id}>
-              {/* 리비전 행 */}
-              <TableRow className="bg-muted/20">
-                {row.getVisibleCells().map((cell) => (
-                  <TableCell key={cell.id}>
-                    {cell.column.id === "expander" ? (
-                      <div
-                        onClick={() => {
-                          row.toggleExpanded();
-                          handleRevisionExpand(row.original.id, row.getIsExpanded());
-                        }}
-                      >
-                        {flexRender(
-                          cell.column.columnDef.cell,
-                          cell.getContext()
+    <div className="space-y-4">
+      {/* 전체 펼치기/접기 버튼 */}
+      <div className="flex justify-end">
+        <Button
+          variant="outline"
+          size="sm"
+          onClick={handleExpandAll}
+        >
+          {allExpanded ? "모두 접기" : "모두 펼치기"}
+        </Button>
+      </div>
+
+      {/* 리비전 테이블 */}
+      <div className="rounded-md border">
+        <Table>
+          <TableHeader>
+            {revisionTable.getHeaderGroups().map((headerGroup) => (
+              <TableRow key={headerGroup.id} className="bg-muted/50">
+                {headerGroup.headers.map((header) => (
+                  <TableHead key={header.id} className="font-semibold">
+                    {header.isPlaceholder
+                      ? null
+                      : flexRender(
+                          header.column.columnDef.header,
+                          header.getContext()
                         )}
-                      </div>
-                    ) : (
-                      flexRender(cell.column.columnDef.cell, cell.getContext())
-                    )}
-                  </TableCell>
+                  </TableHead>
                 ))}
               </TableRow>
-
-              {/* 파일 행들 (확장 시) */}
-              {row.getIsExpanded() && (
-                <TableRow>
-                  <TableCell colSpan={swpRevisionColumns.length} className="p-0 bg-blue-50/30">
-                    {loadingFiles.has(row.original.id) ? (
-                      <div className="flex items-center justify-center p-4">
-                        <Loader2 className="h-5 w-5 animate-spin" />
-                        <span className="ml-2 text-sm">파일 로딩 중...</span>
-                      </div>
-                    ) : fileData[row.original.id]?.length ? (
-                      <FileSubTable files={fileData[row.original.id]} />
-                    ) : (
-                      <div className="p-4 text-center text-sm text-muted-foreground">
-                        파일 없음
-                      </div>
-                    )}
-                  </TableCell>
+            ))}
+          </TableHeader>
+          <TableBody>
+            {revisionTable.getRowModel().rows.map((row) => (
+              <React.Fragment key={row.id}>
+                {/* 리비전 행 */}
+                <TableRow className="bg-muted/20">
+                  {row.getVisibleCells().map((cell) => (
+                    <TableCell key={cell.id}>
+                      {cell.column.id === "expander" ? (
+                        <div
+                          onClick={() => {
+                            row.toggleExpanded();
+                            if (!row.getIsExpanded()) {
+                              handleRevisionExpand(row.original.id);
+                            }
+                          }}
+                          className="cursor-pointer"
+                        >
+                          {flexRender(
+                            cell.column.columnDef.cell,
+                            cell.getContext()
+                          )}
+                        </div>
+                      ) : (
+                        flexRender(cell.column.columnDef.cell, cell.getContext())
+                      )}
+                    </TableCell>
+                  ))}
                 </TableRow>
-              )}
-            </React.Fragment>
-          ))}
-        </TableBody>
-      </Table>
+
+                {/* 파일 행들 (확장 시) */}
+                {row.getIsExpanded() && (
+                  <TableRow>
+                    <TableCell colSpan={swpRevisionColumns.length} className="p-0 bg-blue-50/30">
+                      {loadingFiles.has(row.original.id) ? (
+                        <div className="flex items-center justify-center p-4">
+                          <Loader2 className="h-5 w-5 animate-spin" />
+                          <span className="ml-2 text-sm">파일 로딩 중...</span>
+                        </div>
+                      ) : fileData[row.original.id]?.length ? (
+                        <FileSubTable files={fileData[row.original.id]} />
+                      ) : (
+                        <div className="p-4 text-center text-sm text-muted-foreground">
+                          파일 없음
+                        </div>
+                      )}
+                    </TableCell>
+                  </TableRow>
+                )}
+              </React.Fragment>
+            ))}
+          </TableBody>
+        </Table>
+      </div>
     </div>
   );
 }
-- 
cgit v1.2.3


From 231c4eb86771a44b24248ca403fcbb8c44fff74b Mon Sep 17 00:00:00 2001
From: joonhoekim <26rote@gmail.com>
Date: Fri, 24 Oct 2025 19:44:04 +0900
Subject: (김준회) SWP 파일 업로드 처리, 다운로드는 임시 처리(네트워크경로에서
 다운로드받도록)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

---
 lib/swp/actions.ts                  | 458 ++++++++++++++++++++++++++++++++++++
 lib/swp/table/swp-help-dialog.tsx   | 153 ++++++++++++
 lib/swp/table/swp-table-columns.tsx |  83 ++++++-
 lib/swp/table/swp-table-toolbar.tsx | 139 ++++++++++-
 4 files changed, 809 insertions(+), 24 deletions(-)
 create mode 100644 lib/swp/table/swp-help-dialog.tsx

(limited to 'lib/swp/table/swp-table-columns.tsx')

diff --git a/lib/swp/actions.ts b/lib/swp/actions.ts
index 694936ab..7411f414 100644
--- a/lib/swp/actions.ts
+++ b/lib/swp/actions.ts
@@ -5,6 +5,8 @@ import { swpDocuments, swpDocumentRevisions, swpDocumentFiles } from "@/db/schem
 import { eq, and, sql, like, desc, asc, type SQL } from "drizzle-orm";
 import { fetchSwpProjectData } from "./api-client";
 import { syncSwpProject } from "./sync-service";
+import * as fs from "fs/promises";
+import * as path from "path";
 
 // ============================================================================
 // 타입 정의
@@ -291,3 +293,459 @@ export async function fetchSwpStats(projNo?: string) {
   }
 }
 
+// ============================================================================
+// 서버 액션: 파일 다운로드
+// ============================================================================
+
+export interface DownloadFileResult {
+  success: boolean;
+  data?: Uint8Array;
+  fileName?: string;
+  mimeType?: string;
+  error?: string;
+}
+
+export async function downloadSwpFile(fileId: number): Promise<DownloadFileResult> {
+  try {
+    // 1. 파일 정보 조회
+    const fileInfo = await db
+      .select({
+        FILE_NM: swpDocumentFiles.FILE_NM,
+        FLD_PATH: swpDocumentFiles.FLD_PATH,
+      })
+      .from(swpDocumentFiles)
+      .where(eq(swpDocumentFiles.id, fileId))
+      .limit(1);
+
+    if (!fileInfo || fileInfo.length === 0) {
+      return {
+        success: false,
+        error: "파일 정보를 찾을 수 없습니다.",
+      };
+    }
+
+    const { FILE_NM, FLD_PATH } = fileInfo[0];
+
+    if (!FLD_PATH || !FILE_NM) {
+      return {
+        success: false,
+        error: "파일 경로 또는 파일명이 없습니다.",
+      };
+    }
+
+    // 2. NFS 마운트 경로 확인
+    const nfsBasePath = process.env.DOCUMENTUM_NFS;
+    if (!nfsBasePath) {
+      console.error("[downloadSwpFile] DOCUMENTUM_NFS 환경변수가 설정되지 않았습니다.");
+      return {
+        success: false,
+        error: "서버 설정 오류: NFS 경로가 설정되지 않았습니다.",
+      };
+    }
+
+    // 3. 전체 파일 경로 생성
+    // FLD_PATH가 절대 경로일 수도 있고 상대 경로일 수도 있으므로 처리
+    const fullPath = path.join(nfsBasePath, FLD_PATH, FILE_NM);
+
+    console.log("[downloadSwpFile] 파일 다운로드 시도:", {
+      fileId,
+      FILE_NM,
+      FLD_PATH,
+      fullPath,
+    });
+
+    // 4. 파일 존재 여부 확인
+    try {
+      await fs.access(fullPath, fs.constants.R_OK);
+    } catch (accessError) {
+      console.error("[downloadSwpFile] 파일 접근 불가:", accessError);
+      return {
+        success: false,
+        error: `파일을 찾을 수 없습니다: ${FILE_NM}`,
+      };
+    }
+
+    // 5. 파일 읽기
+    const fileBuffer = await fs.readFile(fullPath);
+    const fileData = new Uint8Array(fileBuffer);
+
+    // 6. MIME 타입 결정
+    const mimeType = getMimeType(FILE_NM);
+
+    console.log("[downloadSwpFile] 파일 다운로드 성공:", {
+      fileName: FILE_NM,
+      size: fileData.length,
+      mimeType,
+    });
+
+    return {
+      success: true,
+      data: fileData,
+      fileName: FILE_NM,
+      mimeType,
+    };
+  } catch (error) {
+    console.error("[downloadSwpFile] 오류:", error);
+    return {
+      success: false,
+      error: error instanceof Error ? error.message : "파일 다운로드 실패",
+    };
+  }
+}
+
+// ============================================================================
+// 헬퍼: MIME 타입 결정
+// ============================================================================
+
+function getMimeType(fileName: string): string {
+  const ext = path.extname(fileName).toLowerCase();
+  
+  const mimeTypes: Record<string, string> = {
+    ".pdf": "application/pdf",
+    ".doc": "application/msword",
+    ".docx": "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
+    ".xls": "application/vnd.ms-excel",
+    ".xlsx": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
+    ".ppt": "application/vnd.ms-powerpoint",
+    ".pptx": "application/vnd.openxmlformats-officedocument.presentationml.presentation",
+    ".txt": "text/plain",
+    ".csv": "text/csv",
+    ".jpg": "image/jpeg",
+    ".jpeg": "image/jpeg",
+    ".png": "image/png",
+    ".gif": "image/gif",
+    ".zip": "application/zip",
+    ".rar": "application/x-rar-compressed",
+    ".7z": "application/x-7z-compressed",
+    ".dwg": "application/acad",
+    ".dxf": "application/dxf",
+  };
+
+  return mimeTypes[ext] || "application/octet-stream";
+}
+
+// ============================================================================
+// 서버 액션: 파일 업로드 (네트워크 경로 기반)
+// ============================================================================
+
+export interface UploadFileInfo {
+  fileName: string;
+  fileBuffer: Buffer;
+}
+
+export interface UploadFilesResult {
+  success: boolean;
+  message: string;
+  successCount: number;
+  failedCount: number;
+  details: Array<{
+    fileName: string;
+    success: boolean;
+    error?: string;
+    networkPath?: string;
+  }>;
+}
+
+/**
+ * 파일명 파싱: [OWN_DOC_NO]_[REV_NO]_[STAGE]_[YYYYMMDDhhmmss].[확장자]
+ */
+function parseFileName(fileName: string) {
+  // 확장자 분리
+  const lastDotIndex = fileName.lastIndexOf(".");
+  const extension = lastDotIndex !== -1 ? fileName.substring(lastDotIndex + 1) : "";
+  const nameWithoutExt = lastDotIndex !== -1 ? fileName.substring(0, lastDotIndex) : fileName;
+
+  // _ 기준으로 분리 (정확히 3개의 _가 있어야 함)
+  const parts = nameWithoutExt.split("_");
+  
+  if (parts.length !== 4) {
+    throw new Error(
+      `잘못된 파일명 형식입니다: ${fileName}. ` +
+      `형식: [OWN_DOC_NO]_[REV_NO]_[STAGE]_[YYYYMMDDhhmmss].확장자`
+    );
+  }
+
+  const [ownDocNo, revNo, stage, timestamp] = parts;
+
+  // 타임스탬프 검증 (14자리 숫자)
+  if (!/^\d{14}$/.test(timestamp)) {
+    throw new Error(
+      `잘못된 타임스탬프 형식입니다: ${timestamp}. ` +
+      `YYYYMMDDhhmmss 형식이어야 합니다.`
+    );
+  }
+
+  return {
+    ownDocNo,
+    revNo,
+    stage,
+    timestamp,
+    extension,
+  };
+}
+
+/**
+ * CPY_CD 조회: swpDocuments 테이블에서 PROJ_NO와 VNDR_CD로 조회
+ */
+async function getCpyCdForVendor(projNo: string, vndrCd: string): Promise<string> {
+  const result = await db
+    .select({
+      CPY_CD: swpDocuments.CPY_CD,
+    })
+    .from(swpDocuments)
+    .where(
+      and(
+        eq(swpDocuments.PROJ_NO, projNo),
+        eq(swpDocuments.VNDR_CD, vndrCd)
+      )
+    )
+    .limit(1);
+
+  if (!result || result.length === 0 || !result[0].CPY_CD) {
+    throw new Error(
+      `프로젝트 ${projNo}에서 벤더 코드 ${vndrCd}에 해당하는 회사 코드(CPY_CD)를 찾을 수 없습니다.`
+    );
+  }
+
+  return result[0].CPY_CD;
+}
+
+/**
+ * 네트워크 경로 생성
+ */
+function generateNetworkPath(
+  projNo: string,
+  cpyCd: string,
+  timestamp: string,
+  fileName: string
+): string {
+  const swpMountDir = process.env.SWP_MOUNT_DIR || "/mnt/swp-smb-dir/";
+  return path.join(swpMountDir, projNo, cpyCd, timestamp, fileName);
+}
+
+/**
+ * InBox 파일 정보 인터페이스
+ */
+interface InBoxFileInfo {
+  CPY_CD: string;
+  FILE_NM: string;
+  OFDC_NO: string | null;
+  PROJ_NO: string;
+  OWN_DOC_NO: string;
+  REV_NO: string;
+  STAGE: string;
+  STAT: string;
+  FILE_SZ: string;
+  FLD_PATH: string;
+}
+
+/**
+ * SaveInBoxList API 호출
+ */
+async function callSaveInBoxList(fileInfos: InBoxFileInfo[]): Promise<void> {
+  const ddcUrl = process.env.DDC_BASE_URL || "http://60.100.99.217/DDC/Services/WebService.svc";
+  const url = `${ddcUrl}/SaveInBoxList`;
+
+  const request = {
+    externalInboxLists: fileInfos,
+  };
+
+  console.log("[callSaveInBoxList] 요청:", JSON.stringify(request, null, 2));
+
+  const response = await fetch(url, {
+    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(`SaveInBoxList API 호출 실패: ${response.statusText} - ${errorText}`);
+  }
+
+  const data = await response.json();
+  console.log("[callSaveInBoxList] 응답:", JSON.stringify(data, null, 2));
+
+  // 응답 검증
+  if (data.SaveInBoxListResult && !data.SaveInBoxListResult.success) {
+    throw new Error(
+      `SaveInBoxList API 실패: ${data.SaveInBoxListResult.message || "알 수 없는 오류"}`
+    );
+  }
+}
+
+/**
+ * GetExternalInboxList API 응답 인터페이스
+ */
+interface ExternalInboxItem {
+  DOC_NO?: string;
+  REV_NO?: string;
+  STAGE?: string;
+  FILE_NM?: string;
+  FILE_SZ?: string;
+  [key: string]: unknown;
+}
+
+/**
+ * GetExternalInboxList API 호출
+ */
+async function callGetExternalInboxList(projNo: string, cpyCd: string): Promise<ExternalInboxItem[]> {
+  const ddcUrl = process.env.DDC_BASE_URL || "http://60.100.99.217/DDC/Services/WebService.svc";
+  const params = new URLSearchParams({
+    PROJ_NO: projNo,
+    CPY_CD: cpyCd,
+  });
+  const url = `${ddcUrl}/GetExternalInboxList?${params.toString()}`;
+
+  console.log("[callGetExternalInboxList] 요청:", url);
+
+  const response = await fetch(url, {
+    method: "GET",
+    headers: {
+      Accept: "application/json",
+    },
+  });
+
+  if (!response.ok) {
+    const errorText = await response.text();
+    throw new Error(`GetExternalInboxList API 호출 실패: ${response.statusText} - ${errorText}`);
+  }
+
+  const data = await response.json();
+  console.log("[callGetExternalInboxList] 응답:", JSON.stringify(data, null, 2));
+
+  return data.GetExternalInboxListResult || [];
+}
+
+/**
+ * 파일 업로드 서버 액션
+ */
+export async function uploadSwpFilesAction(
+  projNo: string,
+  vndrCd: string,
+  files: UploadFileInfo[]
+): Promise<UploadFilesResult> {
+  const result: UploadFilesResult = {
+    success: true,
+    message: "",
+    successCount: 0,
+    failedCount: 0,
+    details: [],
+  };
+
+  try {
+    // 1. CPY_CD 조회
+    console.log(`[uploadSwpFilesAction] CPY_CD 조회: projNo=${projNo}, vndrCd=${vndrCd}`);
+    const cpyCd = await getCpyCdForVendor(projNo, vndrCd);
+    console.log(`[uploadSwpFilesAction] CPY_CD: ${cpyCd}`);
+
+    // 2. 각 파일 처리
+    const inBoxFileInfos: InBoxFileInfo[] = [];
+
+    for (const file of files) {
+      try {
+        // 2-1. 파일명 파싱
+        const parsed = parseFileName(file.fileName);
+        console.log(`[uploadSwpFilesAction] 파일명 파싱:`, parsed);
+
+        // 2-2. 네트워크 경로 생성
+        const networkPath = generateNetworkPath(
+          projNo,
+          cpyCd,
+          parsed.timestamp,
+          file.fileName
+        );
+
+        // 2-3. 파일 중복 체크
+        try {
+          await fs.access(networkPath, fs.constants.F_OK);
+          // 파일이 이미 존재하는 경우
+          result.failedCount++;
+          result.details.push({
+            fileName: file.fileName,
+            success: false,
+            error: "파일이 이미 존재합니다.",
+          });
+          console.warn(`[uploadSwpFilesAction] 파일 중복: ${networkPath}`);
+          continue;
+        } catch {
+          // 파일이 존재하지 않음 (정상)
+        }
+
+        // 2-4. 디렉토리 생성
+        const directory = path.dirname(networkPath);
+        await fs.mkdir(directory, { recursive: true });
+
+        // 2-5. 파일 저장
+        await fs.writeFile(networkPath, file.fileBuffer);
+        console.log(`[uploadSwpFilesAction] 파일 저장 완료: ${networkPath}`);
+
+        // 2-6. InBox 파일 정보 준비
+        const dateOnly = parsed.timestamp.substring(0, 8); // YYYYMMDD
+        const fldPath = `\\\\${projNo}\\\\${cpyCd}\\\\${dateOnly}`;
+
+        inBoxFileInfos.push({
+          CPY_CD: cpyCd,
+          FILE_NM: file.fileName,
+          OFDC_NO: null,
+          PROJ_NO: projNo,
+          OWN_DOC_NO: parsed.ownDocNo,
+          REV_NO: parsed.revNo,
+          STAGE: parsed.stage,
+          STAT: "SCW01",
+          FILE_SZ: String(file.fileBuffer.length),
+          FLD_PATH: fldPath,
+        });
+
+        result.successCount++;
+        result.details.push({
+          fileName: file.fileName,
+          success: true,
+          networkPath,
+        });
+      } catch (error) {
+        result.failedCount++;
+        result.details.push({
+          fileName: file.fileName,
+          success: false,
+          error: error instanceof Error ? error.message : "알 수 없는 오류",
+        });
+        console.error(`[uploadSwpFilesAction] 파일 처리 실패: ${file.fileName}`, error);
+      }
+    }
+
+    // 3. SaveInBoxList API 호출 (성공한 파일만)
+    if (inBoxFileInfos.length > 0) {
+      console.log(`[uploadSwpFilesAction] SaveInBoxList API 호출: ${inBoxFileInfos.length}개 파일`);
+      await callSaveInBoxList(inBoxFileInfos);
+    }
+
+    // 4. GetExternalInboxList API 호출
+    console.log(`[uploadSwpFilesAction] GetExternalInboxList API 호출`);
+    const inboxList = await callGetExternalInboxList(projNo, cpyCd);
+    console.log(`[uploadSwpFilesAction] InBox 목록: ${inboxList.length}개`);
+
+    // 5. 결과 메시지 생성
+    if (result.failedCount === 0) {
+      result.message = `${result.successCount}개 파일이 성공적으로 업로드되었습니다.`;
+    } else if (result.successCount === 0) {
+      result.success = false;
+      result.message = `모든 파일 업로드에 실패했습니다. (${result.failedCount}개)`;
+    } else {
+      result.message = 
+        `${result.successCount}개 파일 업로드 성공, ${result.failedCount}개 실패`;
+    }
+
+    console.log(`[uploadSwpFilesAction] 완료:`, result);
+    return result;
+  } catch (error) {
+    console.error("[uploadSwpFilesAction] 오류:", error);
+    result.success = false;
+    result.message = error instanceof Error ? error.message : "알 수 없는 오류가 발생했습니다.";
+    return result;
+  }
+}
+
diff --git a/lib/swp/table/swp-help-dialog.tsx b/lib/swp/table/swp-help-dialog.tsx
new file mode 100644
index 00000000..18f29644
--- /dev/null
+++ b/lib/swp/table/swp-help-dialog.tsx
@@ -0,0 +1,153 @@
+"use client";
+
+import { HelpCircle } from "lucide-react";
+import { Button } from "@/components/ui/button";
+import {
+  Dialog,
+  DialogContent,
+  DialogDescription,
+  DialogHeader,
+  DialogTitle,
+  DialogTrigger,
+} from "@/components/ui/dialog";
+import { Badge } from "@/components/ui/badge";
+
+export function SwpUploadHelpDialog() {
+  return (
+    <Dialog>
+      <DialogTrigger asChild>
+        <Button variant="outline" size="sm" className="gap-2">
+          <HelpCircle className="h-4 w-4" />
+          업로드 가이드
+        </Button>
+      </DialogTrigger>
+      <DialogContent className="max-w-2xl" opacityControl={false}>
+        <DialogHeader>
+          <DialogTitle>파일 업로드 가이드</DialogTitle>
+          <DialogDescription>
+            올바른 파일명 형식으로 업로드해주세요
+          </DialogDescription>
+        </DialogHeader>
+        
+        <div className="space-y-6">
+          {/* 파일명 형식 */}
+          <div className="space-y-2">
+            <h3 className="text-sm font-semibold">📋 파일명 형식</h3>
+            <div className="rounded-lg bg-muted p-4 font-mono text-sm">
+              [OWN_DOC_NO]_[REV_NO]_[STAGE]_[YYYYMMDDhhmmss].[확장자]
+            </div>
+            <p className="text-xs text-muted-foreground">
+              ⚠️ 언더스코어(_)가 정확히 3개 있어야 합니다
+            </p>
+          </div>
+
+          {/* 각 항목 설명 - 1라인 형태 */}
+          <div className="space-y-3">
+            <h3 className="text-sm font-semibold">📝 항목 설명</h3>
+            
+            <div className="flex items-center gap-3 rounded-lg border p-3">
+              <Badge variant="secondary" className="font-mono shrink-0">
+                OWN_DOC_NO
+              </Badge>
+              <div className="text-sm">
+                <span className="font-medium">벤더의 문서번호</span>
+                <span className="text-muted-foreground"> - 프로젝트마다 유니크해야 합니다</span>
+              </div>
+            </div>
+
+            <div className="flex items-center gap-3 rounded-lg border p-3">
+              <Badge variant="secondary" className="font-mono shrink-0">
+                REV_NO
+              </Badge>
+              <div className="text-sm">
+                <span className="font-medium">리비전 번호</span>
+                <span className="text-muted-foreground"> - 보통 01, 02 같은 식으로 피드백에 따라 증가합니다</span>
+              </div>
+            </div>
+
+            <div className="flex items-center gap-3 rounded-lg border p-3">
+              <Badge variant="secondary" className="font-mono shrink-0">
+                STAGE
+              </Badge>
+              <div className="text-sm">
+                <span className="font-medium">스테이지</span>
+                <span className="text-muted-foreground"> - 중공업이 설정한 스테이지입니다 (예: IFA, IFC, AFC, BFC)</span>
+              </div>
+            </div>
+
+            <div className="flex items-center gap-3 rounded-lg border p-3">
+              <Badge variant="secondary" className="font-mono shrink-0">
+                YYYYMMDDhhmmss
+              </Badge>
+              <div className="text-sm">
+                <span className="font-medium">날짜 및 시간</span>
+                <span className="text-muted-foreground"> - 업로드 날짜 정보를 기입합니다 (14자리 숫자)</span>
+              </div>
+            </div>
+          </div>
+
+          {/* 예시 */}
+          <div className="space-y-2">
+            <h3 className="text-sm font-semibold">✅ 올바른 예시</h3>
+            <div className="space-y-2">
+              <div className="rounded-lg bg-green-50 dark:bg-green-950/30 border border-green-200 dark:border-green-800 p-3">
+                <code className="text-xs font-mono text-green-700 dark:text-green-300">
+                  VD-DOC-001_01_IFA_20250124143000.pdf
+                </code>
+              </div>
+              <div className="rounded-lg bg-green-50 dark:bg-green-950/30 border border-green-200 dark:border-green-800 p-3">
+                <code className="text-xs font-mono text-green-700 dark:text-green-300">
+                  TECH-SPEC-002_02_IFC_20250124150000.dwg
+                </code>
+              </div>
+            </div>
+          </div>
+
+          {/* 잘못된 예시 */}
+          <div className="space-y-2">
+            <h3 className="text-sm font-semibold">❌ 잘못된 예시</h3>
+            <div className="space-y-2">
+              <div className="rounded-lg bg-red-50 dark:bg-red-950/30 border border-red-200 dark:border-red-800 p-3">
+                <code className="text-xs font-mono text-red-700 dark:text-red-300">
+                  VD-DOC-001-01-IFA-20250124.pdf
+                </code>
+                <p className="text-xs text-red-600 dark:text-red-400 mt-1">
+                  ✗ 언더스코어(_) 대신 하이픈(-) 사용
+                </p>
+              </div>
+              <div className="rounded-lg bg-red-50 dark:bg-red-950/30 border border-red-200 dark:border-red-800 p-3">
+                <code className="text-xs font-mono text-red-700 dark:text-red-300">
+                  VD-DOC-001_01_IFA.pdf
+                </code>
+                <p className="text-xs text-red-600 dark:text-red-400 mt-1">
+                  ✗ 날짜/시간 정보 누락
+                </p>
+              </div>
+              <div className="rounded-lg bg-red-50 dark:bg-red-950/30 border border-red-200 dark:border-red-800 p-3">
+                <code className="text-xs font-mono text-red-700 dark:text-red-300">
+                  VD-DOC-001_01_IFA_20250124.pdf
+                </code>
+                <p className="text-xs text-red-600 dark:text-red-400 mt-1">
+                  ✗ 시간 정보 누락 (14자리가 아님)
+                </p>
+              </div>
+            </div>
+          </div>
+
+          {/* 주의사항 */}
+          <div className="rounded-lg bg-amber-50 dark:bg-amber-950/30 border border-amber-200 dark:border-amber-800 p-4">
+            <h3 className="text-sm font-semibold text-amber-900 dark:text-amber-100 mb-2">
+              ⚠️ 주의사항
+            </h3>
+            <ul className="text-xs text-amber-800 dark:text-amber-200 space-y-1 list-disc list-inside">
+              <li>파일명 형식이 올바르지 않으면 업로드가 실패합니다</li>
+              <li>같은 파일명으로 이미 업로드된 파일이 있으면 덮어쓰지 않고 오류 처리됩니다</li>
+              <li>프로젝트와 업체 코드를 먼저 선택해야 업로드 버튼이 활성화됩니다</li>
+            </ul>
+          </div>
+        </div>
+      </DialogContent>
+    </Dialog>
+  );
+}
+
diff --git a/lib/swp/table/swp-table-columns.tsx b/lib/swp/table/swp-table-columns.tsx
index b18e2b27..573acf1b 100644
--- a/lib/swp/table/swp-table-columns.tsx
+++ b/lib/swp/table/swp-table-columns.tsx
@@ -3,10 +3,13 @@
 import { ColumnDef } from "@tanstack/react-table";
 import { Badge } from "@/components/ui/badge";
 import { Button } from "@/components/ui/button";
-import { ChevronDown, ChevronRight, FileIcon, Download } from "lucide-react";
+import { ChevronDown, ChevronRight, FileIcon, Download, Loader2 } from "lucide-react";
 import { formatDistanceToNow } from "date-fns";
 import { ko } from "date-fns/locale";
 import type { SwpDocumentWithStats } from "../actions";
+import { downloadSwpFile } from "../actions";
+import { useState } from "react";
+import { toast } from "sonner";
 
 export const swpDocumentColumns: ColumnDef<SwpDocumentWithStats>[] = [
   {
@@ -388,19 +391,75 @@ export const swpFileColumns: ColumnDef<FileRow>[] = [
     id: "actions",
     header: "작업",
     cell: ({ row }) => (
-      <Button
-        variant="outline"
-        size="sm"
-        onClick={() => {
-          // TODO: 파일 다운로드 로직 구현
-          console.log("Download file:", row.original.FILE_NM);
-        }}
-      >
-        <Download className="h-4 w-4 mr-1" />
-        다운로드
-      </Button>
+      <DownloadButton fileId={row.original.id} fileName={row.original.FILE_NM} />
     ),
     size: 120,
   },
 ];
 
+// ============================================================================
+// 다운로드 버튼 컴포넌트: 임시 구성. Download.aspx 동작 안해서 일단 네트워크드라이브 사용하도록 처리
+// ============================================================================
+
+interface DownloadButtonProps {
+  fileId: number;
+  fileName: string;
+}
+
+function DownloadButton({ fileId, fileName }: DownloadButtonProps) {
+  const [isDownloading, setIsDownloading] = useState(false);
+
+  const handleDownload = async () => {
+    try {
+      setIsDownloading(true);
+      
+      // 서버 액션 호출
+      const result = await downloadSwpFile(fileId);
+
+      if (!result.success || !result.data) {
+        toast.error(result.error || "파일 다운로드 실패");
+        return;
+      }
+
+      // Blob 생성 및 다운로드
+      const blob = new Blob([result.data as unknown as BlobPart], { type: result.mimeType });
+      const url = window.URL.createObjectURL(blob);
+      const link = document.createElement("a");
+      link.href = url;
+      link.download = result.fileName || fileName;
+      document.body.appendChild(link);
+      link.click();
+      document.body.removeChild(link);
+      window.URL.revokeObjectURL(url);
+
+      toast.success(`파일 다운로드 완료: ${result.fileName}`);
+    } catch (error) {
+      console.error("다운로드 오류:", error);
+      toast.error("파일 다운로드 중 오류가 발생했습니다.");
+    } finally {
+      setIsDownloading(false);
+    }
+  };
+
+  return (
+    <Button
+      variant="outline"
+      size="sm"
+      onClick={handleDownload}
+      disabled={isDownloading}
+    >
+      {isDownloading ? (
+        <>
+          <Loader2 className="h-4 w-4 mr-1 animate-spin" />
+          다운로드 중...
+        </>
+      ) : (
+        <>
+          <Download className="h-4 w-4 mr-1" />
+          다운로드
+        </>
+      )}
+    </Button>
+  );
+}
+
diff --git a/lib/swp/table/swp-table-toolbar.tsx b/lib/swp/table/swp-table-toolbar.tsx
index 6858f42e..03082b26 100644
--- a/lib/swp/table/swp-table-toolbar.tsx
+++ b/lib/swp/table/swp-table-toolbar.tsx
@@ -16,11 +16,13 @@ import {
   PopoverTrigger,
 } from "@/components/ui/popover";
 import { Label } from "@/components/ui/label";
-import { RefreshCw, Download, Search, X, Check, ChevronsUpDown } from "lucide-react";
-import { syncSwpProjectAction, type SwpTableFilters } from "../actions";
+import { RefreshCw, Search, X, Check, ChevronsUpDown, Upload } from "lucide-react";
+import { syncSwpProjectAction, uploadSwpFilesAction, type SwpTableFilters } from "../actions";
 import { useToast } from "@/hooks/use-toast";
 import { useRouter } from "next/navigation";
 import { cn } from "@/lib/utils";
+import { useRef } from "react";
+import { SwpUploadHelpDialog } from "./swp-help-dialog";
 
 interface SwpTableToolbarProps {
   filters: SwpTableFilters;
@@ -34,11 +36,13 @@ export function SwpTableToolbar({
   projects = [],
 }: SwpTableToolbarProps) {
   const [isSyncing, startSync] = useTransition();
+  const [isUploading, startUpload] = useTransition();
   const [localFilters, setLocalFilters] = useState<SwpTableFilters>(filters);
   const { toast } = useToast();
   const router = useRouter();
   const [projectSearchOpen, setProjectSearchOpen] = useState(false);
   const [projectSearch, setProjectSearch] = useState("");
+  const fileInputRef = useRef<HTMLInputElement>(null);
 
   // 동기화 핸들러
   const handleSync = () => {
@@ -86,17 +90,112 @@ export function SwpTableToolbar({
 
   /**
    * 파일 업로드 핸들러
-   * 1) 네트워크 드라이브에 정해진 규칙대로, 파일이름 기반으로 파일 업로드하기 (단, cpyCd는 어떻게 해결할지 고민해봐야 함...)
+   * 1) 네트워크 드라이브에 정해진 규칙대로, 파일이름 기반으로 파일 업로드하기
    * 2) 1~N개 파일 받아서, 파일 이름 기준으로 파싱해서 SaveInBoxList API를 통해 업로드 처리
-   * 
-   * 개발중인 동안은 토스트 반환하도록 처리
   */ 
   const handleUploadFiles = () => {
-    toast({
-      title: "파일 업로드",
-      description: "현재 개발중입니다.",
+    // 프로젝트와 벤더 코드 체크
+    const projectNo = localFilters.projNo;
+    const vndrCd = localFilters.vndrCd;
+
+    if (!projectNo) {
+      toast({
+        variant: "destructive",
+        title: "프로젝트 선택 필요",
+        description: "파일을 업로드할 프로젝트를 먼저 선택해주세요.",
+      });
+      return;
+    }
+
+    if (!vndrCd) {
+      toast({
+        variant: "destructive",
+        title: "업체 코드 입력 필요",
+        description: "파일을 업로드할 업체 코드를 입력해주세요.",
+      });
+      return;
+    }
+
+    // 파일 선택 다이얼로그 열기
+    fileInputRef.current?.click();
+  };
+
+  /**
+   * 파일 선택 핸들러
+   */
+  const handleFileChange = async (event: React.ChangeEvent<HTMLInputElement>) => {
+    const selectedFiles = event.target.files;
+    if (!selectedFiles || selectedFiles.length === 0) {
+      return;
+    }
+
+    const projectNo = localFilters.projNo!;
+    const vndrCd = localFilters.vndrCd!;
+
+    startUpload(async () => {
+      try {
+        toast({
+          title: "파일 업로드 시작",
+          description: `${selectedFiles.length}개 파일을 업로드합니다...`,
+        });
+
+        // 파일을 Buffer로 변환
+        const fileInfos = await Promise.all(
+          Array.from(selectedFiles).map(async (file) => {
+            const arrayBuffer = await file.arrayBuffer();
+            return {
+              fileName: file.name,
+              fileBuffer: Buffer.from(arrayBuffer),
+            };
+          })
+        );
+
+        // 서버 액션 호출
+        const result = await uploadSwpFilesAction(projectNo, vndrCd, fileInfos);
+
+        if (result.success) {
+          toast({
+            title: "업로드 완료",
+            description: result.message,
+          });
+
+          // 페이지 새로고침
+          router.refresh();
+        } else {
+          toast({
+            variant: "destructive",
+            title: "업로드 실패",
+            description: result.message,
+          });
+        }
+
+        // 실패한 파일이 있으면 상세 정보 표시
+        const failedFiles = result.details.filter((d) => !d.success);
+        if (failedFiles.length > 0) {
+          console.error("실패한 파일:", failedFiles);
+          failedFiles.forEach((f) => {
+            toast({
+              variant: "destructive",
+              title: `${f.fileName} 업로드 실패`,
+              description: f.error || "알 수 없는 오류",
+            });
+          });
+        }
+      } catch (error) {
+        console.error("파일 업로드 실패:", error);
+        toast({
+          variant: "destructive",
+          title: "업로드 실패",
+          description: error instanceof Error ? error.message : "알 수 없는 오류",
+        });
+      } finally {
+        // 파일 입력 초기화
+        if (fileInputRef.current) {
+          fileInputRef.current.value = "";
+        }
+      }
     });
-  }
+  };
 
   // 검색 적용
   const handleSearch = () => {
@@ -141,10 +240,26 @@ export function SwpTableToolbar({
         <div className="text-sm text-muted-foreground">
           SWP 문서 관리 시스템
         </div>
-        <div>
-          <Button variant="outline" size="sm" onClick={handleUploadFiles}>
-            파일 업로드하기
+        <div className="flex items-center gap-2">
+          <input
+            ref={fileInputRef}
+            type="file"
+            multiple
+            className="hidden"
+            onChange={handleFileChange}
+            accept="*/*"
+          />
+          <Button 
+            variant="outline" 
+            size="sm" 
+            onClick={handleUploadFiles}
+            disabled={isUploading || !localFilters.projNo || !localFilters.vndrCd}
+          >
+            <Upload className={`h-4 w-4 mr-2 ${isUploading ? "animate-pulse" : ""}`} />
+            {isUploading ? "업로드 중..." : "파일 업로드"}
           </Button>
+          
+          <SwpUploadHelpDialog />
         </div>
       </div>
 
-- 
cgit v1.2.3

