From a1710296dbc1881a7ed86093872904a529901430 Mon Sep 17 00:00:00 2001 From: joonhoekim <26rote@gmail.com> Date: Tue, 4 Nov 2025 17:30:27 +0900 Subject: (김준회) RFQ 테이블 POS 다운로드할 수 있도록 변경, 벤더측은 다운로드시 암호화 해제 추가, item dialog 통일처리 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/api/pos/download-on-demand-partners/route.ts | 243 ++++++++++ lib/pos/components/pos-file-selection-dialog.tsx | 7 +- lib/rfq-last/shared/rfq-items-dialog.tsx | 489 +++++++++++++++++++++ lib/rfq-last/table/rfq-items-dialog.tsx | 479 -------------------- lib/rfq-last/table/rfq-table.tsx | 3 +- .../editor/quotation-items-table.tsx | 32 -- lib/rfq-last/vendor-response/rfq-items-dialog.tsx | 360 --------------- .../vendor-response/vendor-quotations-table.tsx | 3 +- lib/swp/table/swp-table-columns.tsx | 2 +- 9 files changed, 740 insertions(+), 878 deletions(-) create mode 100644 app/api/pos/download-on-demand-partners/route.ts create mode 100644 lib/rfq-last/shared/rfq-items-dialog.tsx delete mode 100644 lib/rfq-last/table/rfq-items-dialog.tsx delete mode 100644 lib/rfq-last/vendor-response/rfq-items-dialog.tsx diff --git a/app/api/pos/download-on-demand-partners/route.ts b/app/api/pos/download-on-demand-partners/route.ts new file mode 100644 index 00000000..d2941537 --- /dev/null +++ b/app/api/pos/download-on-demand-partners/route.ts @@ -0,0 +1,243 @@ +'use server'; + +/** + * POS 파일 온디맨드 다운로드 API (Partners용 - 복호화 포함) + * + * 자재코드(MATNR)로 POS 파일을 찾아서 NFS에서 다운로드 후 복호화하여 제공합니다. + * EVCP 직원용과 달리 협력사는 복호화된 파일을 다운로드합니다. + */ + +import { NextRequest, NextResponse } from 'next/server'; +import { getDcmtmIdByMaterialCode, getEncryptDocumentumFile, downloadPosFile } from '@/lib/pos'; + +// 허용된 파일 확장자 +const ALLOWED_EXTENSIONS = new Set([ + 'pdf', 'doc', 'docx', 'xls', 'xlsx', 'ppt', 'pptx', + 'txt', 'csv', 'png', 'jpg', 'jpeg', 'gif', 'bmp', 'svg', + 'dwg', 'dxf', 'zip', 'rar', '7z' +]); + +// 최대 파일 크기 (10240MB) +const MAX_FILE_SIZE = 10240 * 1024 * 1024; + +// 파일 확장자 검증 +function validateFileExtension(fileName: string): boolean { + const extension = fileName.split('.').pop()?.toLowerCase() || ''; + return ALLOWED_EXTENSIONS.has(extension); +} + +// MIME 타입 결정 +function getMimeType(fileName: string): string { + const fileExtension = fileName.split('.').pop()?.toLowerCase() || ''; + const mimeTypes: Record = { + '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; charset=utf-8', + 'csv': 'text/csv; charset=utf-8', + 'png': 'image/png', + 'jpg': 'image/jpeg', + 'jpeg': 'image/jpeg', + 'gif': 'image/gif', + 'bmp': 'image/bmp', + 'svg': 'image/svg+xml', + 'dwg': 'application/acad', + 'dxf': 'application/dxf', + 'zip': 'application/zip', + 'rar': 'application/x-rar-compressed', + '7z': 'application/x-7z-compressed', + }; + return mimeTypes[fileExtension] || 'application/octet-stream'; +} + +/** + * DRM 복호화 함수 + * Spring Boot DRM Proxy 서버를 통해 파일 복호화 + */ +async function decryptBuffer(buffer: Buffer, fileName: string): Promise { + try { + console.log(`🔐 DRM 복호화 시작: ${fileName} (크기: ${buffer.length} bytes)`); + + // 파일을 Blob/File로 변환 + const blob = new Blob([buffer]); + const file = new File([blob], fileName); + + const formData = new FormData(); + formData.append('file', file); + + // DRM Proxy 서버 엔드포인트 + const backendUrl = process.env.DRM_PROXY_URL || "http://localhost:6543/api/drm-proxy/decrypt"; + + const response = await fetch(backendUrl, { + method: "POST", + body: formData, + }); + + if (!response.ok) { + const errorText = await response.text().catch(() => '응답 텍스트를 가져올 수 없음'); + throw new Error(`DRM 서버 응답 오류 [${response.status}]: ${errorText}`); + } + + const arrayBuffer = await response.arrayBuffer(); + const decryptedBuffer = Buffer.from(arrayBuffer); + + console.log(`✅ DRM 복호화 완료: ${fileName} (결과 크기: ${decryptedBuffer.length} bytes)`); + + return decryptedBuffer; + } catch (error) { + console.error(`❌ DRM 복호화 실패: ${fileName}`, error); + // 복호화 실패 시 원본 반환 (폴백) + console.warn(`⚠️ 복호화 실패로 원본 파일 반환: ${fileName}`); + return buffer; + } +} + +export async function GET(request: NextRequest) { + try { + const searchParams = request.nextUrl.searchParams; + const materialCode = searchParams.get('materialCode'); + const fileIndex = parseInt(searchParams.get('fileIndex') || '0'); + + // 파라미터 검증 + if (!materialCode || materialCode.trim() === '') { + return NextResponse.json( + { error: '자재코드(materialCode)가 제공되지 않았습니다.' }, + { status: 400 } + ); + } + + console.log(`🔍 POS 파일 온디맨드 다운로드 시작 [Partners - 복호화] (자재코드: ${materialCode})`); + + // 1. 자재코드로 DCMTM_ID 조회 + console.log(`📋 DCMTM_ID 조회 중... (자재코드: ${materialCode})`); + const dcmtmResult = await getDcmtmIdByMaterialCode({ materialCode }); + + if (!dcmtmResult.success || !dcmtmResult.files || dcmtmResult.files.length === 0) { + console.warn(`⚠️ POS 파일을 찾을 수 없음 (자재코드: ${materialCode})`); + return NextResponse.json( + { + error: dcmtmResult.error || '해당 자재코드에 대한 POS 파일을 찾을 수 없습니다.', + materialCode + }, + { status: 404 } + ); + } + + // 파일 인덱스 범위 검증 + if (fileIndex >= dcmtmResult.files.length) { + return NextResponse.json( + { + error: `파일 인덱스가 범위를 벗어났습니다. 사용 가능한 파일 수: ${dcmtmResult.files.length}`, + availableFilesCount: dcmtmResult.files.length + }, + { status: 400 } + ); + } + + const posFile = dcmtmResult.files[fileIndex]; + console.log(`✅ DCMTM_ID 조회 완료:`, { + materialCode, + dcmtmId: posFile.dcmtmId, + fileName: posFile.fileName, + totalFiles: dcmtmResult.files.length + }); + + // 2. POS API로 파일 경로 가져오기 + console.log(`🌐 POS API 호출 중... (DCMTM_ID: ${posFile.dcmtmId})`); + const posResult = await getEncryptDocumentumFile({ + objectID: posFile.dcmtmId + }); + + if (!posResult.success || !posResult.result) { + console.error(`❌ POS API 호출 실패:`, posResult.error); + return NextResponse.json( + { + error: posResult.error || 'POS 파일 경로를 가져올 수 없습니다.', + materialCode, + dcmtmId: posFile.dcmtmId + }, + { status: 500 } + ); + } + + console.log(`✅ POS API 호출 완료 (경로: ${posResult.result})`); + + // 3. NFS에서 파일 다운로드 + console.log(`⬇️ 파일 다운로드 중... (NFS 경로: ${posResult.result})`); + const downloadResult = await downloadPosFile({ + relativePath: posResult.result + }); + + if (!downloadResult.success || !downloadResult.fileBuffer) { + console.error(`❌ 파일 다운로드 실패:`, downloadResult.error); + return NextResponse.json( + { + error: downloadResult.error || '파일 다운로드에 실패했습니다.', + materialCode, + nfsPath: posResult.result + }, + { status: 500 } + ); + } + + const fileName = downloadResult.fileName || posFile.fileName; + let fileBuffer = downloadResult.fileBuffer; + + console.log(`✅ 파일 다운로드 완료 (크기: ${fileBuffer.length} bytes)`); + + // 파일 확장자 검증 + if (!validateFileExtension(fileName)) { + console.warn(`🚨 허용되지 않은 파일 확장자: ${fileName}`); + return NextResponse.json( + { error: '지원하지 않는 파일 형식입니다.', fileName }, + { status: 403 } + ); + } + + // 파일 크기 검증 + if (fileBuffer.length > MAX_FILE_SIZE) { + console.warn(`🚨 파일 크기 초과: ${fileBuffer.length} bytes`); + return NextResponse.json( + { error: '파일 크기가 너무 큽니다.', fileSize: fileBuffer.length }, + { status: 413 } + ); + } + + // 4. DRM 복호화 (Partners용 추가 로직) + fileBuffer = await decryptBuffer(fileBuffer, fileName); + + // MIME 타입 결정 + const contentType = downloadResult.mimeType || getMimeType(fileName); + + // 파일 스트리밍 응답 생성 + const response = new NextResponse(fileBuffer); + + response.headers.set('Content-Type', contentType); + response.headers.set('Content-Disposition', `attachment; filename*=UTF-8''${encodeURIComponent(fileName)}`); + response.headers.set('Content-Length', fileBuffer.length.toString()); + + // 보안 헤더 + response.headers.set('Cache-Control', 'no-cache, no-store, must-revalidate'); + response.headers.set('Pragma', 'no-cache'); + response.headers.set('Expires', '0'); + response.headers.set('X-Content-Type-Options', 'nosniff'); + + console.log(`✅ POS 파일 다운로드 성공 [Partners - 복호화됨]: ${fileName} (${fileBuffer.length} bytes)`); + + return response; + } catch (error) { + console.error('❌ POS 파일 온디맨드 다운로드 API 오류 [Partners]:', error); + return NextResponse.json( + { + error: '서버 내부 오류가 발생했습니다.', + details: error instanceof Error ? error.message : '알 수 없는 오류' + }, + { status: 500 } + ); + } +} + diff --git a/lib/pos/components/pos-file-selection-dialog.tsx b/lib/pos/components/pos-file-selection-dialog.tsx index 29936d21..0553754d 100644 --- a/lib/pos/components/pos-file-selection-dialog.tsx +++ b/lib/pos/components/pos-file-selection-dialog.tsx @@ -18,7 +18,6 @@ import { TableRow, } from "@/components/ui/table" import { Button } from "@/components/ui/button" -import { ScrollArea } from "@/components/ui/scroll-area" import { Badge } from "@/components/ui/badge" interface PosFileInfo { @@ -49,7 +48,7 @@ export function PosFileSelectionDialog({ }: PosFileSelectionDialogProps) { return ( - + @@ -62,7 +61,7 @@ export function PosFileSelectionDialog({ - +
@@ -119,7 +118,7 @@ export function PosFileSelectionDialog({ ))}
- +
{files.length === 0 && (
diff --git a/lib/rfq-last/shared/rfq-items-dialog.tsx b/lib/rfq-last/shared/rfq-items-dialog.tsx new file mode 100644 index 00000000..c25670fc --- /dev/null +++ b/lib/rfq-last/shared/rfq-items-dialog.tsx @@ -0,0 +1,489 @@ +"use client" + +import * as React from "react" +import { format } from "date-fns" +import { Package, ExternalLink, Download, FileText } from "lucide-react" +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog" +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/components/ui/table" +import { Badge } from "@/components/ui/badge" +import { Button } from "@/components/ui/button" +import { ScrollArea } from "@/components/ui/scroll-area" +import { Skeleton } from "@/components/ui/skeleton" +import { Separator } from "@/components/ui/separator" +import { toast } from "sonner" +import { RfqsLastView, VendorQuotationView } from "@/db/schema" +import { getRfqItemsAction } from "../service" +import { getDownloadUrlByMaterialCode, checkPosFileExists } from "@/lib/pos" +import { PosFileSelectionDialog } from "@/lib/pos/components/pos-file-selection-dialog" + +// 품목 타입 +interface RfqItem { + id: number + rfqsLastId: number | null + rfqItem: string | null + prItem: string | null + prNo: string | null + materialCode: string | null + materialCategory: string | null + acc: string | null + materialDescription: string | null + size: string | null + deliveryDate: Date | null + quantity: number | null + uom: string | null + grossWeight: number | null + gwUom: string | null + specNo: string | null + specUrl: string | null + trackingNo: string | null + majorYn: boolean | null + remark: string | null + projectDef: string | null + projectSc: string | null + projectKl: string | null + projectLc: string | null + projectDl: string | null + // RFQ 관련 정보 + rfqCode: string | null + rfqType: string | null + rfqTitle: string | null + itemCode: string | null + itemName: string | null + projectCode: string | null + projectName: string | null +} + +interface ItemStatistics { + total: number + major: number + regular: number + totalQuantity: number + totalWeight: number +} + +interface RfqItemsDialogProps { + isOpen: boolean + onClose: () => void + rfqData: RfqsLastView | VendorQuotationView + /** + * 뷰어 타입 + * - 'evcp': EVCP 사용자 (암호화된 파일 직접 다운로드) + * - 'partners': 협력사 사용자 (복호화된 파일 다운로드) + */ + viewerType?: 'evcp' | 'partners' +} + +export function RfqItemsDialog({ + isOpen, + onClose, + rfqData, + viewerType = 'evcp' +}: RfqItemsDialogProps) { + const [items, setItems] = React.useState([]) + const [statistics, setStatistics] = React.useState(null) + const [isLoading, setIsLoading] = React.useState(false) + // POS 파일 선택 다이얼로그 상태 + const [posDialogOpen, setPosDialogOpen] = React.useState(false) + const [selectedMaterialCode, setSelectedMaterialCode] = React.useState("") + const [posFiles, setPosFiles] = React.useState>([]) + const [loadingPosFiles, setLoadingPosFiles] = React.useState(false) + const [downloadingFileIndex, setDownloadingFileIndex] = React.useState(null) + + // 품목 목록 로드 + React.useEffect(() => { + if (!isOpen || !rfqData.id) return + + const loadData = async () => { + setIsLoading(true) + + try { + // 품목 목록 로드 + const itemsResult = await getRfqItemsAction(rfqData.id) + + if (itemsResult.success) { + setItems(itemsResult.data) + setStatistics(itemsResult.statistics || null) + } else { + toast.error(itemsResult.error || "품목을 불러오는데 실패했습니다") + setItems([]) + setStatistics(null) + } + + } catch (error) { + console.error("데이터 로드 오류:", error) + toast.error("데이터를 불러오는데 실패했습니다") + setItems([]) + setStatistics(null) + } finally { + setIsLoading(false) + } + } + + loadData() + }, [isOpen, rfqData.id]) + + // 사양서 링크 열기 + const handleOpenSpec = (specUrl: string) => { + window.open(specUrl, '_blank', 'noopener,noreferrer') + } + + // POS 파일 목록 조회 및 다이얼로그 열기 + const handleOpenPosDialog = async (materialCode: string) => { + if (!materialCode) { + toast.error("자재코드가 없습니다") + return + } + + setLoadingPosFiles(true) + setSelectedMaterialCode(materialCode) + + try { + toast.loading(`POS 파일 목록 조회 중... (${materialCode})`, { id: `pos-check-${materialCode}` }) + + const result = await checkPosFileExists(materialCode) + + if (result.exists && result.files && result.files.length > 0) { + // 파일 정보를 상세하게 가져오기 위해 getDownloadUrlByMaterialCode 사용 + const detailResult = await getDownloadUrlByMaterialCode(materialCode) + + if (detailResult.success && detailResult.availableFiles) { + setPosFiles(detailResult.availableFiles) + setPosDialogOpen(true) + toast.success(`${result.fileCount}개의 POS 파일을 찾았습니다`, { id: `pos-check-${materialCode}` }) + } else { + toast.error('POS 파일 정보를 가져올 수 없습니다', { id: `pos-check-${materialCode}` }) + } + } else { + toast.error(result.error || 'POS 파일을 찾을 수 없습니다', { id: `pos-check-${materialCode}` }) + } + } catch (error) { + console.error("POS 파일 조회 오류:", error) + toast.error("POS 파일 조회에 실패했습니다", { id: `pos-check-${materialCode}` }) + } finally { + setLoadingPosFiles(false) + } + } + + // POS 파일 다운로드 실행 + const handleDownloadPosFile = async (fileIndex: number, fileName: string) => { + if (!selectedMaterialCode) return + + setDownloadingFileIndex(fileIndex) + + try { + toast.loading(`POS 파일 다운로드 준비 중...`, { id: `download-${fileIndex}` }) + + // viewerType에 따라 다른 엔드포인트 사용 + const endpoint = viewerType === 'partners' + ? `/api/pos/download-on-demand-partners` // 복호화 포함 + : `/api/pos/download-on-demand` // 암호화 파일 그대로 + + const downloadUrl = `${endpoint}?materialCode=${encodeURIComponent(selectedMaterialCode)}&fileIndex=${fileIndex}` + + toast.success(`POS 파일 다운로드 시작: ${fileName}`, { id: `download-${fileIndex}` }) + window.open(downloadUrl, '_blank', 'noopener,noreferrer') + + // 다운로드 시작 후 잠시 대기 후 상태 초기화 + setTimeout(() => { + setDownloadingFileIndex(null) + }, 1000) + } catch (error) { + console.error("POS 파일 다운로드 오류:", error) + toast.error("POS 파일 다운로드에 실패했습니다", { id: `download-${fileIndex}` }) + setDownloadingFileIndex(null) + } + } + + // POS 다이얼로그 닫기 + const handleClosePosDialog = () => { + setPosDialogOpen(false) + setSelectedMaterialCode("") + setPosFiles([]) + setDownloadingFileIndex(null) + } + + + return ( + + + + 견적 품목 목록 + + {rfqData.rfqCode} - {rfqData.rfqTitle || rfqData.itemName || "품목 정보"} + + + + {/* 통계 정보 */} + {statistics && !isLoading && ( + <> +
+
+
{statistics.total}
+
전체 품목
+
+
+
{statistics.regular}
+
일반 품목
+
+
+
{statistics.totalQuantity.toLocaleString()}
+
총 수량
+
+
+
{statistics.totalWeight.toLocaleString()}
+
총 중량 (KG)
+
+
+ + + )} + + + {isLoading ? ( + + + + 아이템 + 자재코드 + 자재명 + 수량 + 수량단위 + 중량 + 중량단위 + 납기일 + PR번호 + 사양/설계문서 + 비고 + + + + {[...Array(3)].map((_, i) => ( + + + + + + + + + + + + + + ))} + +
+ ) : items.length === 0 ? ( +
+ +

품목이 없습니다.

+
+ ) : ( + + + + 아이템 + 자재코드 + 자재명 + 수량 + 수량단위 + 중량 + 중량단위 + 납기일 + PR번호 + PR 아이템 번호 + 사양/설계문서 + 프로젝트 + 비고 + + + + {items.map((item, index) => ( + + +
+ #{index + 1} + {item.majorYn && ( + + 주요 + + )} +
+
+ +
+ {item.materialCode || "-"} + {item.acc && ( + + ACC: {item.acc} + + )} +
+
+ +
+ + {item.materialDescription || "-"} + + {item.materialCategory && ( + + {item.materialCategory} + + )} + {item.size && ( + + 크기: {item.size} + + )} +
+
+ + + {item.quantity ? item.quantity.toLocaleString() : "-"} + + + + + {item.uom || "-"} + + + + + {item.grossWeight ? item.grossWeight.toLocaleString() : "-"} + + + + + {item.gwUom || "-"} + + + + + {item.deliveryDate ? format(new Date(item.deliveryDate), "yyyy-MM-dd") : "-"} + + + + {item.prNo || "-"} + + + {item.prItem || "-"} + + +
+ {/* 기존 스펙 정보 */} +
+ {item.specNo && ( + {item.specNo} + )} + {item.specUrl && ( + + )} +
+ + {/* POS 파일 다운로드 */} + {item.materialCode && ( +
+ + +
+ )} + + {/* 트래킹 번호 */} + {item.trackingNo && ( +
+ TRK: {item.trackingNo} +
+ )} +
+
+ +
+ {[ + item.projectDef && `${item.projectDef}`, + item.projectSc && `SC: ${item.projectSc}`, + item.projectKl && `KL: ${item.projectKl}`, + item.projectLc && `LC: ${item.projectLc}`, + item.projectDl && `DL: ${item.projectDl}` + ].filter(Boolean).join(" | ") || "-"} +
+
+ + + {item.remark ? (item.remark.length > 30 ? `${item.remark.slice(0, 30)}...` : item.remark) : "-"} + + +
+ ))} +
+
+ )} +
+ + {/* 하단 통계 정보 */} + {statistics && !isLoading && ( +
+
+ + 총 {statistics.total}개 품목 + + + 전체 수량: {statistics.totalQuantity.toLocaleString()} | + 전체 중량: {statistics.totalWeight.toLocaleString()} KG + +
+
+ )} + + {/* POS 파일 선택 다이얼로그 */} + +
+
+ ) +} + diff --git a/lib/rfq-last/table/rfq-items-dialog.tsx b/lib/rfq-last/table/rfq-items-dialog.tsx deleted file mode 100644 index 5f8e4382..00000000 --- a/lib/rfq-last/table/rfq-items-dialog.tsx +++ /dev/null @@ -1,479 +0,0 @@ -"use client" - -import * as React from "react" -import { format } from "date-fns" -import { Package, ExternalLink, Download, FileText } from "lucide-react" -import { - Dialog, - DialogContent, - DialogDescription, - DialogHeader, - DialogTitle, -} from "@/components/ui/dialog" -import { - Table, - TableBody, - TableCell, - TableHead, - TableHeader, - TableRow, -} from "@/components/ui/table" -import { Badge } from "@/components/ui/badge" -import { Button } from "@/components/ui/button" -import { ScrollArea } from "@/components/ui/scroll-area" -import { Skeleton } from "@/components/ui/skeleton" -import { Separator } from "@/components/ui/separator" -import { toast } from "sonner" -import { RfqsLastView } from "@/db/schema" -import { getRfqItemsAction } from "../service" -import { getDownloadUrlByMaterialCode, checkPosFileExists } from "@/lib/pos" -import { PosFileSelectionDialog } from "@/lib/pos/components/pos-file-selection-dialog" - -// 품목 타입 -interface RfqItem { - id: number - rfqsLastId: number | null - rfqItem: string | null - prItem: string | null - prNo: string | null - materialCode: string | null - materialCategory: string | null - acc: string | null - materialDescription: string | null - size: string | null - deliveryDate: Date | null - quantity: number | null - uom: string | null - grossWeight: number | null - gwUom: string | null - specNo: string | null - specUrl: string | null - trackingNo: string | null - majorYn: boolean | null - remark: string | null - projectDef: string | null - projectSc: string | null - projectKl: string | null - projectLc: string | null - projectDl: string | null - // RFQ 관련 정보 - rfqCode: string | null - rfqType: string | null - rfqTitle: string | null - itemCode: string | null - itemName: string | null - projectCode: string | null - projectName: string | null -} - -interface ItemStatistics { - total: number - major: number - regular: number - totalQuantity: number - totalWeight: number -} - -interface RfqItemsDialogProps { - isOpen: boolean - onClose: () => void - rfqData: RfqsLastView -} - -export function RfqItemsDialog({ isOpen, onClose, rfqData }: RfqItemsDialogProps) { - const [items, setItems] = React.useState([]) - const [statistics, setStatistics] = React.useState(null) - const [isLoading, setIsLoading] = React.useState(false) - // POS 파일 선택 다이얼로그 상태 - const [posDialogOpen, setPosDialogOpen] = React.useState(false) - const [selectedMaterialCode, setSelectedMaterialCode] = React.useState("") - const [posFiles, setPosFiles] = React.useState>([]) - const [loadingPosFiles, setLoadingPosFiles] = React.useState(false) - const [downloadingFileIndex, setDownloadingFileIndex] = React.useState(null) - - // 품목 목록 로드 - React.useEffect(() => { - if (!isOpen || !rfqData.id) return - - const loadData = async () => { - setIsLoading(true) - - try { - // 품목 목록 로드 - const itemsResult = await getRfqItemsAction(rfqData.id) - - if (itemsResult.success) { - setItems(itemsResult.data) - setStatistics(itemsResult.statistics || null) - } else { - toast.error(itemsResult.error || "품목을 불러오는데 실패했습니다") - setItems([]) - setStatistics(null) - } - - } catch (error) { - console.error("데이터 로드 오류:", error) - toast.error("데이터를 불러오는데 실패했습니다") - setItems([]) - setStatistics(null) - } finally { - setIsLoading(false) - } - } - - loadData() - }, [isOpen, rfqData.id]) - - // 사양서 링크 열기 - const handleOpenSpec = (specUrl: string) => { - window.open(specUrl, '_blank', 'noopener,noreferrer') - } - - // POS 파일 목록 조회 및 다이얼로그 열기 - const handleOpenPosDialog = async (materialCode: string) => { - if (!materialCode) { - toast.error("자재코드가 없습니다") - return - } - - setLoadingPosFiles(true) - setSelectedMaterialCode(materialCode) - - try { - toast.loading(`POS 파일 목록 조회 중... (${materialCode})`, { id: `pos-check-${materialCode}` }) - - const result = await checkPosFileExists(materialCode) - - if (result.exists && result.files && result.files.length > 0) { - // 파일 정보를 상세하게 가져오기 위해 getDownloadUrlByMaterialCode 사용 - const detailResult = await getDownloadUrlByMaterialCode(materialCode) - - if (detailResult.success && detailResult.availableFiles) { - setPosFiles(detailResult.availableFiles) - setPosDialogOpen(true) - toast.success(`${result.fileCount}개의 POS 파일을 찾았습니다`, { id: `pos-check-${materialCode}` }) - } else { - toast.error('POS 파일 정보를 가져올 수 없습니다', { id: `pos-check-${materialCode}` }) - } - } else { - toast.error(result.error || 'POS 파일을 찾을 수 없습니다', { id: `pos-check-${materialCode}` }) - } - } catch (error) { - console.error("POS 파일 조회 오류:", error) - toast.error("POS 파일 조회에 실패했습니다", { id: `pos-check-${materialCode}` }) - } finally { - setLoadingPosFiles(false) - } - } - - // POS 파일 다운로드 실행 - const handleDownloadPosFile = async (fileIndex: number, fileName: string) => { - if (!selectedMaterialCode) return - - setDownloadingFileIndex(fileIndex) - - try { - toast.loading(`POS 파일 다운로드 준비 중...`, { id: `download-${fileIndex}` }) - - const downloadUrl = `/api/pos/download-on-demand?materialCode=${encodeURIComponent(selectedMaterialCode)}&fileIndex=${fileIndex}` - - toast.success(`POS 파일 다운로드 시작: ${fileName}`, { id: `download-${fileIndex}` }) - window.open(downloadUrl, '_blank', 'noopener,noreferrer') - - // 다운로드 시작 후 잠시 대기 후 상태 초기화 - setTimeout(() => { - setDownloadingFileIndex(null) - }, 1000) - } catch (error) { - console.error("POS 파일 다운로드 오류:", error) - toast.error("POS 파일 다운로드에 실패했습니다", { id: `download-${fileIndex}` }) - setDownloadingFileIndex(null) - } - } - - // POS 다이얼로그 닫기 - const handleClosePosDialog = () => { - setPosDialogOpen(false) - setSelectedMaterialCode("") - setPosFiles([]) - setDownloadingFileIndex(null) - } - - - return ( - - - - 견적 품목 목록 - - {rfqData.rfqCode} - {rfqData.rfqTitle || rfqData.itemName || "품목 정보"} - - - - {/* 통계 정보 */} - {statistics && !isLoading && ( - <> -
-
-
{statistics.total}
-
전체 품목
-
- {/*
-
{statistics.major}
-
주요 품목
-
*/} -
-
{statistics.regular}
-
일반 품목
-
-
-
{statistics.totalQuantity.toLocaleString()}
-
총 수량
-
-
-
{statistics.totalWeight.toLocaleString()}
-
총 중량 (KG)
-
-
- - - )} - - - {isLoading ? ( - - - - 아이템 - 자재코드 - 자재명 - 수량 - 수량단위 - 중량 - 중량단위 - 납기일 - PR번호 - 사양/설계문서 - 비고 - - - - {[...Array(3)].map((_, i) => ( - - - - - - - - - - - - - - - - ))} - -
- ) : items.length === 0 ? ( -
- -

품목이 없습니다.

-
- ) : ( - - - - 아이템 - 자재코드 - 자재명 - 수량 - 수량단위 - 중량 - 중량단위 - 납기일 - PR번호 - PR 아이템 번호 - 사양/설계문서 - 프로젝트 - 비고 - - - - {items.map((item, index) => ( - - -
- #{index + 1} - {item.majorYn && ( - - 주요 - - )} -
-
- -
- {item.materialCode || "-"} - {item.acc && ( - - ACC: {item.acc} - - )} -
-
- -
- - {item.materialDescription || "-"} - - {item.materialCategory && ( - - {item.materialCategory} - - )} - {item.size && ( - - 크기: {item.size} - - )} -
-
- - - {item.quantity ? item.quantity.toLocaleString() : "-"} - - - - - {item.uom || "-"} - - - - - {item.grossWeight ? item.grossWeight.toLocaleString() : "-"} - - - - - {item.gwUom || "-"} - - - - - {item.deliveryDate ? format(new Date(item.deliveryDate), "yyyy-MM-dd") : "-"} - - - - {item.prNo || "-"} - - - {item.prItem || "-"} - - -
- {/* 기존 스펙 정보 */} -
- {item.specNo && ( - {item.specNo} - )} - {item.specUrl && ( - - )} -
- - {/* POS 파일 다운로드 */} - {item.materialCode && ( -
- - -
- )} - - {/* 트래킹 번호 */} - {item.trackingNo && ( -
- TRK: {item.trackingNo} -
- )} -
-
- -
- {[ - item.projectDef && `${item.projectDef}`, - item.projectSc && `SC: ${item.projectSc}`, - item.projectKl && `KL: ${item.projectKl}`, - item.projectLc && `LC: ${item.projectLc}`, - item.projectDl && `DL: ${item.projectDl}` - ].filter(Boolean).join(" | ") || "-"} -
-
- - - {item.remark ? (item.remark.length > 30 ? `${item.remark.slice(0, 30)}...` : item.remark) : "-"} - - -
- ))} -
-
- )} -
- - {/* 하단 통계 정보 */} - {statistics && !isLoading && ( -
-
- - 총 {statistics.total}개 품목 - {/* (주요: {statistics.major}개, 일반: {statistics.regular}개) */} - - - 전체 수량: {statistics.totalQuantity.toLocaleString()} | - 전체 중량: {statistics.totalWeight.toLocaleString()} KG - -
-
- )} - - {/* POS 파일 선택 다이얼로그 */} - -
-
- ) -} \ No newline at end of file diff --git a/lib/rfq-last/table/rfq-table.tsx b/lib/rfq-last/table/rfq-table.tsx index 162dd343..46bb4670 100644 --- a/lib/rfq-last/table/rfq-table.tsx +++ b/lib/rfq-last/table/rfq-table.tsx @@ -25,7 +25,7 @@ import { RfqsLastView } from "@/db/schema"; import { getRfqs } from "../service"; import { RfqTableToolbarActions } from "./rfq-table-toolbar-actions"; import { RfqAttachmentsDialog } from "./rfq-attachments-dialog"; -import { RfqItemsDialog } from "./rfq-items-dialog"; +import { RfqItemsDialog } from "../shared/rfq-items-dialog"; interface RfqTableProps { data: Awaited>; @@ -494,6 +494,7 @@ export function RfqTable({ isOpen={true} onClose={() => setRowAction(null)} rfqData={rowAction.row.original} + viewerType="evcp" /> )} diff --git a/lib/rfq-last/vendor-response/editor/quotation-items-table.tsx b/lib/rfq-last/vendor-response/editor/quotation-items-table.tsx index 23ddc924..b0c1488a 100644 --- a/lib/rfq-last/vendor-response/editor/quotation-items-table.tsx +++ b/lib/rfq-last/vendor-response/editor/quotation-items-table.tsx @@ -64,15 +64,6 @@ export default function QuotationItemsTable({ prItems }: QuotationItemsTableProp console.log(prItems,"prItems") - // 통계 정보 계산 - const statistics = { - total: prItems.length, - regular: prItems.filter(item => !item.majorYn).length, - major: prItems.filter(item => item.majorYn).length, - totalQuantity: prItems.reduce((sum, item) => sum + (item.quantity || 0), 0), - totalWeight: prItems.reduce((sum, item) => sum + (item.grossWeight || 0), 0), - } - // PR 아이템 정보를 quotationItems에 초기화 useEffect(() => { if (prItems && prItems.length > 0) { @@ -474,29 +465,6 @@ export default function QuotationItemsTable({ prItems }: QuotationItemsTableProp
- - {/* 통계 정보 */} -
-
-
-
{statistics.total}
-
전체 품목
-
-
-
{statistics.regular}
-
일반 품목
-
-
-
{statistics.totalQuantity.toLocaleString()}
-
총 수량
-
-
-
{statistics.totalWeight.toLocaleString()}
-
총 중량 (KG)
-
-
- -
diff --git a/lib/rfq-last/vendor-response/rfq-items-dialog.tsx b/lib/rfq-last/vendor-response/rfq-items-dialog.tsx deleted file mode 100644 index 9790a1bd..00000000 --- a/lib/rfq-last/vendor-response/rfq-items-dialog.tsx +++ /dev/null @@ -1,360 +0,0 @@ -"use client" - -import * as React from "react" -import { format } from "date-fns" -import { Package, ExternalLink } from "lucide-react" -import { - Dialog, - DialogContent, - DialogDescription, - DialogHeader, - DialogTitle, -} from "@/components/ui/dialog" -import { - Table, - TableBody, - TableCell, - TableHead, - TableHeader, - TableRow, -} from "@/components/ui/table" -import { Badge } from "@/components/ui/badge" -import { Button } from "@/components/ui/button" -import { ScrollArea } from "@/components/ui/scroll-area" -import { Skeleton } from "@/components/ui/skeleton" -import { Separator } from "@/components/ui/separator" -import { toast } from "sonner" -import { RfqsLastView } from "@/db/schema" -import { getRfqItemsAction } from "../service" - -// 품목 타입 -interface RfqItem { - id: number - rfqsLastId: number | null - rfqItem: string | null - prItem: string | null - prNo: string | null - materialCode: string | null - materialCategory: string | null - acc: string | null - materialDescription: string | null - size: string | null - deliveryDate: Date | null - quantity: number | null - uom: string | null - grossWeight: number | null - gwUom: string | null - specNo: string | null - specUrl: string | null - trackingNo: string | null - majorYn: boolean | null - remark: string | null - projectDef: string | null - projectSc: string | null - projectKl: string | null - projectLc: string | null - projectDl: string | null - // RFQ 관련 정보 - rfqCode: string | null - rfqType: string | null - rfqTitle: string | null - itemCode: string | null - itemName: string | null - projectCode: string | null - projectName: string | null -} - -interface ItemStatistics { - total: number - major: number - regular: number - totalQuantity: number - totalWeight: number -} - -interface RfqItemsDialogProps { - isOpen: boolean - onClose: () => void - rfqData: RfqsLastView -} - -export function RfqItemsDialog({ isOpen, onClose, rfqData }: RfqItemsDialogProps) { - const [items, setItems] = React.useState([]) - const [statistics, setStatistics] = React.useState(null) - const [isLoading, setIsLoading] = React.useState(false) - - // 품목 목록 로드 - React.useEffect(() => { - if (!isOpen || !rfqData.id) return - - const loadItems = async () => { - setIsLoading(true) - try { - const result = await getRfqItemsAction(rfqData.id) - - if (result.success) { - setItems(result.data) - setStatistics(result.statistics ?? null) - } else { - toast.error(result.error || "품목을 불러오는데 실패했습니다") - setItems([]) - setStatistics(null) - } - } catch (error) { - console.error("품목 로드 오류:", error) - toast.error("품목을 불러오는데 실패했습니다") - setItems([]) - setStatistics(null) - } finally { - setIsLoading(false) - } - } - - loadItems() - }, [isOpen, rfqData.id]) - - // 사양서 링크 열기 - const handleOpenSpec = (specUrl: string) => { - window.open(specUrl, '_blank', 'noopener,noreferrer') - } - - - return ( - - - - 견적 품목 목록 - - {rfqData.rfqCode} - {rfqData.rfqTitle || rfqData.itemName || "품목 정보"} - - - - {/* 통계 정보 */} - {statistics && !isLoading && ( - <> -
-
-
{statistics.total}
-
전체 품목
-
-
-
{statistics.major}
-
주요 품목
-
-
-
{statistics.regular}
-
일반 품목
-
-
-
{statistics.totalQuantity.toLocaleString()}
-
총 수량
-
-
-
{statistics.totalWeight.toLocaleString()}
-
총 중량 (KG)
-
-
- - - )} - - - {isLoading ? ( - - - - 구분 - 자재코드 - 자재명 - 수량 - 수량단위 - 중량 - 중량단위 - 납기일 - PR번호 - 사양 - 비고 - - - - {[...Array(3)].map((_, i) => ( - - - - - - - - - - - - - - - ))} - -
- ) : items.length === 0 ? ( -
- -

품목이 없습니다.

-
- ) : ( - - - - 구분 - 자재코드 - 자재명 - 수량 - 수량단위 - 중량 - 중량단위 - 납기일 - PR번호 - 사양 - 프로젝트 - 비고 - - - - {items.map((item, index) => ( - - -
- #{index + 1} - {item.majorYn && ( - - 주요 - - )} -
-
- -
- {item.materialCode || "-"} - {item.acc && ( - - ACC: {item.acc} - - )} -
-
- -
- - {item.materialDescription || "-"} - - {item.materialCategory && ( - - {item.materialCategory} - - )} - {item.size && ( - - 크기: {item.size} - - )} -
-
- - - {item.quantity ? item.quantity.toLocaleString() : "-"} - - - - - {item.uom || "-"} - - - - - {item.grossWeight ? item.grossWeight.toLocaleString() : "-"} - - - - - {item.gwUom || "-"} - - - - - {item.deliveryDate ? format(new Date(item.deliveryDate), "yyyy-MM-dd") : "-"} - - - -
- {item.prNo || "-"} - {item.prItem && item.prItem !== item.prNo && ( - - {item.prItem} - - )} -
-
- -
- {item.specNo && ( - {item.specNo} - )} - {item.specUrl && ( - - )} - {item.trackingNo && ( -
- TRK: {item.trackingNo} -
- )} -
-
- -
- {[ - item.projectDef && `${item.projectDef}`, - item.projectSc && `SC: ${item.projectSc}`, - item.projectKl && `KL: ${item.projectKl}`, - item.projectLc && `LC: ${item.projectLc}`, - item.projectDl && `DL: ${item.projectDl}` - ].filter(Boolean).join(" | ") || "-"} -
-
- - - {item.remark ? (item.remark.length > 30 ? `${item.remark.slice(0, 30)}...` : item.remark) : "-"} - - -
- ))} -
-
- )} -
- - {/* 하단 통계 정보 */} - {statistics && !isLoading && ( -
-
- - 총 {statistics.total}개 품목 - (주요: {statistics.major}개, 일반: {statistics.regular}개) - - - 전체 수량: {statistics.totalQuantity.toLocaleString()} | - 전체 중량: {statistics.totalWeight.toLocaleString()} KG - -
-
- )} -
-
- ) -} \ No newline at end of file diff --git a/lib/rfq-last/vendor-response/vendor-quotations-table.tsx b/lib/rfq-last/vendor-response/vendor-quotations-table.tsx index 2e4975f1..dccd9b6c 100644 --- a/lib/rfq-last/vendor-response/vendor-quotations-table.tsx +++ b/lib/rfq-last/vendor-response/vendor-quotations-table.tsx @@ -13,7 +13,7 @@ import { DataTableAdvancedToolbar } from "@/components/data-table/data-table-adv import { useRouter } from "next/navigation" import { getColumns } from "./vendor-quotations-table-columns" import { RfqAttachmentsDialog } from "./rfq-attachments-dialog"; -import { RfqItemsDialog } from "./rfq-items-dialog"; +import { RfqItemsDialog } from "../shared/rfq-items-dialog"; import { VendorQuotationView } from "@/db/schema" interface VendorQuotationsTableLastProps { @@ -163,6 +163,7 @@ export function VendorQuotationsTableLast({ promises }: VendorQuotationsTableLas isOpen={true} onClose={() => setRowAction(null)} rfqData={rowAction.row.original} + viewerType="partners" /> )} diff --git a/lib/swp/table/swp-table-columns.tsx b/lib/swp/table/swp-table-columns.tsx index 09207be8..f42e7a7a 100644 --- a/lib/swp/table/swp-table-columns.tsx +++ b/lib/swp/table/swp-table-columns.tsx @@ -131,7 +131,7 @@ export const swpDocumentColumns: ColumnDef[] = [ }, { id: "actions", - header: "액션", + header: "커버페이지 다운로드", cell: function ActionCell({ row }) { const [isDownloading, setIsDownloading] = React.useState(false); -- cgit v1.2.3