diff options
| author | joonhoekim <26rote@gmail.com> | 2025-09-09 03:19:41 +0000 |
|---|---|---|
| committer | joonhoekim <26rote@gmail.com> | 2025-09-09 03:19:41 +0000 |
| commit | e06913008f124ce8e7389fbdc1e57206ce9bbb2b (patch) | |
| tree | 010292c2cd6116065d70893055deba230899d4ca | |
| parent | 61fd1ff7162390f9ec2480da74840e58bb0b6ebd (diff) | |
(김준회) 협력업체관리 업체분류, 정규업체등록현황 부 수정, 파일다운로드 오류 수정, 업데이트 시트 수정
| -rw-r--r-- | app/api/vendors/attachments/download-all/route.ts | 34 | ||||
| -rw-r--r-- | config/vendorColumnsConfig.ts | 14 | ||||
| -rw-r--r-- | lib/vendors/service.ts | 37 | ||||
| -rw-r--r-- | lib/vendors/table/attachmentButton.tsx | 7 | ||||
| -rw-r--r-- | lib/vendors/table/update-vendor-sheet.tsx | 6 | ||||
| -rw-r--r-- | lib/vendors/table/vendors-table-columns.tsx | 101 | ||||
| -rw-r--r-- | 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; @@ -47,6 +48,15 @@ export const vendorColumnsConfig: VendorColumnConfig[] = [ }, { + id: "vendorClassification", + label: "업체분류", + excelHeader: "업체분류", + type: "string", + width: 120, + minWidth: 100, + }, + + { id: "vendorName", label: "업체명", excelHeader: "업체명", 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) <Select value={field.value || "NONE"} onValueChange={(value) => { - field.onChange(value === "NONE" ? "" : value); + field.onChange(value === "NONE" ? null : value); }} > <SelectTrigger className="w-full"> diff --git a/lib/vendors/table/vendors-table-columns.tsx b/lib/vendors/table/vendors-table-columns.tsx index 738b8b5f..36809715 100644 --- a/lib/vendors/table/vendors-table-columns.tsx +++ b/lib/vendors/table/vendors-table-columns.tsx @@ -217,7 +217,8 @@ export function getColumns({ setRowAction, router, userId }: GetColumnsProps): C // child column 정의 const childCol: ColumnDef<VendorWithTypeAndMaterials> = { - accessorKey: cfg.id, + accessorKey: cfg.id === "vendorClassification" ? undefined : cfg.id, + id: cfg.id as string, enableResizing: true, header: ({ column }) => ( <DataTableColumnHeaderSimple column={column} title={cfg.label} /> @@ -364,6 +365,57 @@ export function getColumns({ setRowAction, router, userId }: GetColumnsProps): C ); } + // 업체분류 컬럼 처리 - status 값에 따라 업체분류 결정 + if (cfg.id === "vendorClassification") { + const statusVal = row.original.status as StatusType; + + // status 값에 따른 업체분류 매핑 + const getVendorClassification = (status: StatusType): string => { + const classificationMap: Record<StatusType, string> = { + "PENDING_REVIEW": "발굴업체", // 업체발굴 + "REJECTED": "발굴업체", // 가입거절 + "APPROVED": "잠재업체", // 가입승인 + "IN_PQ": "잠재업체", // PQ요청 + "PQ_SUBMITTED": "잠재업체", // PQ제출 + "PQ_FAILED": "잠재업체", // 실사실패 + "PQ_APPROVED": "잠재업체", // 실사통과 + "IN_REVIEW": "잠재업체", // 정규등록검토 + "READY_TO_SEND": "잠재업체", // 정규등록검토 + "ACTIVE": "정규업체", // 정규등록 + "INACTIVE": "중지업체", // 비활성화 + "BLACKLISTED": "중지업체", // 거래금지 + }; + + return classificationMap[status] || "미분류"; + }; + + const classification = getVendorClassification(statusVal); + + // 업체분류별 배지 스타일 + const getBadgeStyle = (classification: string) => { + switch (classification) { + case "발굴업체": + return "bg-orange-50 text-orange-700 border-orange-200"; + case "잠재업체": + return "bg-blue-50 text-blue-700 border-blue-200"; + case "정규업체": + return "bg-green-50 text-green-700 border-green-200"; + case "중지업체": + return "bg-red-50 text-red-700 border-red-200"; + case "폐기업체": + return "bg-gray-50 text-gray-700 border-gray-200"; + default: + return "bg-gray-50 text-gray-700 border-gray-200"; + } + }; + + return ( + <Badge variant="outline" className={getBadgeStyle(classification)}> + {classification} + </Badge> + ); + } + // 업체 분류 컬럼 처리 (별도로 표시하고 싶은 경우) if (cfg.id === "vendorCategory") { const categoryVal = row.original.vendorCategory as string | null; @@ -474,9 +526,54 @@ export function getColumns({ setRowAction, router, userId }: GetColumnsProps): C ); } + // 정규업체등록현황 컬럼 - status 필드 표시 + if (cfg.id === "regularVendorRegistration") { + const statusVal = row.original.status as StatusType; + if (!statusVal) return <span className="text-gray-400">-</span>; + + // status 값을 한글로 변환하여 표시 + const getStatusDisplay = (status: StatusType): string => { + const statusMap: StatusDisplayMap = { + // "PENDING_REVIEW": "가입 신청 중", + // "IN_REVIEW": "심사 중", + // "REJECTED": "심사 거부됨", + // "IN_PQ": "PQ 진행 중", + // "PQ_SUBMITTED": "PQ 제출", + // "PQ_FAILED": "PQ 실패", + // "PQ_APPROVED": "PQ 통과", + // "APPROVED": "승인됨", + // "READY_TO_SEND": "MDG 송부대기", + // "ACTIVE": "활성 상태", + // "INACTIVE": "비활성 상태", + // "BLACKLISTED": "거래 금지" + + // 구매가 제공한 화면정의서 상태로 텍스트 변경, 세분화 필요 + "PENDING_REVIEW": "업체발굴", + "APPROVED": "가입승인", + "REJECTED": "가입거절", + "IN_PQ": "PQ요청", + "PQ_SUBMITTED": "PQ제출", + "PQ_FAILED": "PQ실패", + "PQ_APPROVED": "PQ 통과", + "IN_REVIEW": "정규등록검토", + "READY_TO_SEND": "정규등록검토", + "ACTIVE": "정규등록", + "INACTIVE": "비활성화", + "BLACKLISTED": "거래금지" + }; + return statusMap[status] || status; + }; + + return ( + <span className="text-sm font-medium"> + {getStatusDisplay(statusVal)} + </span> + ); + } + // TODO 컬럼들 (UI만) - 모두 "-" 표시 if (cfg.id === "regularEvaluationGrade" || cfg.id === "faContract" || - cfg.id === "avlRegistration" || cfg.id === "regularVendorRegistration" || + cfg.id === "avlRegistration" || cfg.id === "recentDeliveryNumber" || cfg.id === "recentDeliveryBy") { return <span className="text-gray-400">-</span>; } diff --git a/lib/vendors/validations.ts b/lib/vendors/validations.ts index 917242d3..54b5a29b 100644 --- a/lib/vendors/validations.ts +++ b/lib/vendors/validations.ts @@ -130,7 +130,7 @@ export const updateVendorSchema = z.object({ website: z.string().optional(), status: z.enum(vendors.status.enumValues).optional(), vendorTypeId: z.number().optional(), - isAssociationMember: z.string().optional(), // 성조회가입여부 추가 + isAssociationMember: z.string().nullable().optional(), // 성조회가입여부 추가 // Representative information representativeName: z.string().optional(), |
