From e06913008f124ce8e7389fbdc1e57206ce9bbb2b Mon Sep 17 00:00:00 2001 From: joonhoekim <26rote@gmail.com> Date: Tue, 9 Sep 2025 03:19:41 +0000 Subject: (김준회) 협력업체관리 업체분류, 정규업체등록현황 부 수정, 파일다운로드 오류 수정, 업데이트 시트 수정 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/api/vendors/attachments/download-all/route.ts | 34 ++++++-- config/vendorColumnsConfig.ts | 14 ++- lib/vendors/service.ts | 37 ++------ lib/vendors/table/attachmentButton.tsx | 7 +- lib/vendors/table/update-vendor-sheet.tsx | 6 +- lib/vendors/table/vendors-table-columns.tsx | 101 +++++++++++++++++++++- lib/vendors/validations.ts | 2 +- 7 files changed, 156 insertions(+), 45 deletions(-) diff --git a/app/api/vendors/attachments/download-all/route.ts b/app/api/vendors/attachments/download-all/route.ts index 23f85786..4520cb2d 100644 --- a/app/api/vendors/attachments/download-all/route.ts +++ b/app/api/vendors/attachments/download-all/route.ts @@ -53,9 +53,19 @@ export async function GET(request: NextRequest) { // 파일 읽기 및 ZIP에 추가 await Promise.all( attachments.map(async (attachment) => { - const filePath = path.join(basePath, attachment.filePath); - try { + // 필수 필드 검증 + if (!attachment.filePath || !attachment.fileName) { + console.warn(`첨부파일 정보가 불완전합니다:`, attachment); + return; + } + + // filePath는 이미 /로 시작하므로 public 폴더에서의 상대 경로로 처리 + const normalizedPath = attachment.filePath.startsWith('/') + ? attachment.filePath.slice(1) // 앞의 '/' 제거 + : attachment.filePath; + const filePath = path.join(basePath, normalizedPath); + // 파일 존재 확인 try { await fs.promises.access(filePath, fs.constants.F_OK); @@ -67,15 +77,29 @@ export async function GET(request: NextRequest) { // 파일 읽기 const fileData = await fs.promises.readFile(filePath); - // ZIP에 파일 추가 - zip.file(attachment.fileName, fileData); + // ZIP에 파일 추가 (파일명 중복 방지) + const safeFileName = `${attachment.id}_${attachment.fileName}`; + zip.file(safeFileName, fileData); + + console.log(`ZIP에 파일 추가됨: ${safeFileName}`); } catch (error) { - console.warn(`파일을 처리할 수 없습니다: ${filePath}`, error); + console.warn(`파일을 처리할 수 없습니다: ${attachment.filePath}`, error); // 오류가 있더라도 계속 진행 } }) ); + // ZIP에 추가된 파일이 있는지 확인 + const zipFiles = Object.keys(zip.files); + if (zipFiles.length === 0) { + return NextResponse.json( + { error: '다운로드 가능한 파일이 없습니다.' }, + { status: 404 } + ); + } + + console.log(`ZIP 파일 생성 시작: ${zipFiles.length}개 파일`); + // ZIP 생성 const zipContent = await zip.generateAsync({ type: 'nodebuffer', diff --git a/config/vendorColumnsConfig.ts b/config/vendorColumnsConfig.ts index 35a4abd3..8595b0b2 100644 --- a/config/vendorColumnsConfig.ts +++ b/config/vendorColumnsConfig.ts @@ -5,9 +5,10 @@ import { VendorWithTypeAndMaterials } from "@/db/schema/vendors"; */ export interface VendorColumnConfig { /** - * "조인 결과" 객체(VendorWithTypeAndMaterials)의 어느 필드를 표시할지 + * "조인 결과" 객체(VendorWithTypeAndMaterials)의 어느 필드를 표시할지 + * 또는 계산된 필드명 (예: vendorClassification) */ - id: keyof VendorWithTypeAndMaterials; + id: keyof VendorWithTypeAndMaterials | "vendorClassification"; /** 화면·엑셀에서 보여줄 컬럼명 */ label: string; @@ -46,6 +47,15 @@ export const vendorColumnsConfig: VendorColumnConfig[] = [ minWidth: 60, }, + { + id: "vendorClassification", + label: "업체분류", + excelHeader: "업체분류", + type: "string", + width: 120, + minWidth: 100, + }, + { id: "vendorName", label: "업체명", diff --git a/lib/vendors/service.ts b/lib/vendors/service.ts index 0c8254f2..a22a1551 100644 --- a/lib/vendors/service.ts +++ b/lib/vendors/service.ts @@ -1376,43 +1376,18 @@ interface CreateCompanyInput { export async function downloadVendorAttachments(vendorId: number, fileId?: number) { try { // API 경로 생성 (단일 파일 또는 모든 파일) - const url = fileId + const path = fileId ? `/api/vendors/attachments/download?id=${fileId}&vendorId=${vendorId}` : `/api/vendors/attachments/download-all?vendorId=${vendorId}`; - // fetch 요청 (기본적으로 Blob으로 응답 받기) - const response = await fetch(url, { - method: 'GET', - headers: { - 'Content-Type': 'application/json', - }, - }); - - if (!response.ok) { - throw new Error(`Server responded with ${response.status}: ${response.statusText}`); - } - - // 파일명 가져오기 (Content-Disposition 헤더에서) - const contentDisposition = response.headers.get('content-disposition'); - let fileName = fileId ? `file-${fileId}.zip` : `vendor-${vendorId}-files.zip`; - - if (contentDisposition) { - const matches = /filename[^;=\n]*=((['"]).*?\2|[^;\n]*)/.exec(contentDisposition); - if (matches && matches[1]) { - fileName = matches[1].replace(/['"]/g, ''); - } - } - - // Blob으로 응답 변환 - const blob = await response.blob(); - - // Blob URL 생성 - const blobUrl = window.URL.createObjectURL(blob); + // 서버에서는 URL만 반환하고, 클라이언트에서 실제 다운로드 처리 + // 파일명도 기본값으로 설정 + const fileName = fileId ? `file-${fileId}.zip` : `vendor-${vendorId}-attachments.zip`; return { - url: blobUrl, + url: path, // 상대 경로 반환 fileName, - blob + isServerAction: true // 서버 액션임을 표시 }; } catch (error) { console.error('Download API error:', error); diff --git a/lib/vendors/table/attachmentButton.tsx b/lib/vendors/table/attachmentButton.tsx index 3ffa9c5f..86cf992b 100644 --- a/lib/vendors/table/attachmentButton.tsx +++ b/lib/vendors/table/attachmentButton.tsx @@ -35,9 +35,14 @@ export function AttachmentsButton({ vendorId, hasAttachments, attachmentsList = // 파일 다운로드 트리거 toast.success('첨부파일 다운로드가 시작되었습니다.'); + // 서버 액션에서 상대 URL을 반환하므로 절대 URL로 변환 + const downloadUrl = result.isServerAction + ? `${window.location.origin}${result.url}` + : result.url; + // 다운로드 링크 열기 const a = document.createElement('a'); - a.href = result.url; + a.href = downloadUrl; a.download = result.fileName || '첨부파일.zip'; a.style.display = 'none'; document.body.appendChild(a); diff --git a/lib/vendors/table/update-vendor-sheet.tsx b/lib/vendors/table/update-vendor-sheet.tsx index 5138d299..d8d4ad67 100644 --- a/lib/vendors/table/update-vendor-sheet.tsx +++ b/lib/vendors/table/update-vendor-sheet.tsx @@ -201,7 +201,7 @@ export function UpdateVendorSheet({ vendor, ...props }: UpdateVendorSheetProps) cashFlowRating: vendor?.cashFlowRating ?? "", status: (vendor?.status as any) ?? "ACTIVE", vendorTypeId: vendor?.vendorTypeId ?? undefined, - isAssociationMember: (vendor as any)?.isAssociationMember || "NONE", + isAssociationMember: (vendor as any)?.isAssociationMember ?? "NONE", // 대표자 정보 representativeName: (vendor as any)?.representativeName ?? "", @@ -240,7 +240,7 @@ export function UpdateVendorSheet({ vendor, ...props }: UpdateVendorSheetProps) cashFlowRating: vendor?.cashFlowRating ?? "", status: (vendor?.status as any) ?? "ACTIVE", vendorTypeId: vendor?.vendorTypeId ?? undefined, - isAssociationMember: (vendor as any)?.isAssociationMember || "NONE", + isAssociationMember: (vendor as any)?.isAssociationMember ?? "NONE", // 대표자 정보 representativeName: (vendor as any)?.representativeName ?? "", @@ -547,7 +547,7 @@ export function UpdateVendorSheet({ vendor, ...props }: UpdateVendorSheetProps)