diff options
| author | joonhoekim <26rote@gmail.com> | 2025-11-04 17:30:27 +0900 |
|---|---|---|
| committer | joonhoekim <26rote@gmail.com> | 2025-11-04 17:30:27 +0900 |
| commit | a1710296dbc1881a7ed86093872904a529901430 (patch) | |
| tree | 62e4be5dabba4bf7c337769f391d11ce59154cc4 | |
| parent | 680da9b323db8b8d7cf27c674ab0016ec87bfe81 (diff) | |
(김준회) RFQ 테이블 POS 다운로드할 수 있도록 변경, 벤더측은 다운로드시 암호화 해제 추가, item dialog 통일처리
| -rw-r--r-- | app/api/pos/download-on-demand-partners/route.ts | 243 | ||||
| -rw-r--r-- | lib/pos/components/pos-file-selection-dialog.tsx | 7 | ||||
| -rw-r--r-- | lib/rfq-last/shared/rfq-items-dialog.tsx (renamed from lib/rfq-last/table/rfq-items-dialog.tsx) | 40 | ||||
| -rw-r--r-- | lib/rfq-last/table/rfq-table.tsx | 3 | ||||
| -rw-r--r-- | lib/rfq-last/vendor-response/editor/quotation-items-table.tsx | 32 | ||||
| -rw-r--r-- | lib/rfq-last/vendor-response/rfq-items-dialog.tsx | 360 | ||||
| -rw-r--r-- | lib/rfq-last/vendor-response/vendor-quotations-table.tsx | 3 | ||||
| -rw-r--r-- | lib/swp/table/swp-table-columns.tsx | 2 |
8 files changed, 276 insertions, 414 deletions
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<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; 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<Buffer> { + 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 ( <Dialog open={isOpen} onOpenChange={onClose}> - <DialogContent className="max-w-4xl"> + <DialogContent className="max-w-7xl max-h-[90vh]"> <DialogHeader> <DialogTitle className="flex items-center gap-2"> <FileText className="h-5 w-5 text-green-600" /> @@ -62,7 +61,7 @@ export function PosFileSelectionDialog({ </DialogDescription> </DialogHeader> - <ScrollArea className="max-h-[60vh]"> + <div className="overflow-auto max-h-[70vh]"> <Table> <TableHeader> <TableRow> @@ -119,7 +118,7 @@ export function PosFileSelectionDialog({ ))} </TableBody> </Table> - </ScrollArea> + </div> {files.length === 0 && ( <div className="text-center py-8 text-muted-foreground"> diff --git a/lib/rfq-last/table/rfq-items-dialog.tsx b/lib/rfq-last/shared/rfq-items-dialog.tsx index 5f8e4382..c25670fc 100644 --- a/lib/rfq-last/table/rfq-items-dialog.tsx +++ b/lib/rfq-last/shared/rfq-items-dialog.tsx @@ -24,7 +24,7 @@ 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 { 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" @@ -77,10 +77,21 @@ interface ItemStatistics { interface RfqItemsDialogProps { isOpen: boolean onClose: () => void - rfqData: RfqsLastView + rfqData: RfqsLastView | VendorQuotationView + /** + * 뷰어 타입 + * - 'evcp': EVCP 사용자 (암호화된 파일 직접 다운로드) + * - 'partners': 협력사 사용자 (복호화된 파일 다운로드) + */ + viewerType?: 'evcp' | 'partners' } -export function RfqItemsDialog({ isOpen, onClose, rfqData }: RfqItemsDialogProps) { +export function RfqItemsDialog({ + isOpen, + onClose, + rfqData, + viewerType = 'evcp' +}: RfqItemsDialogProps) { const [items, setItems] = React.useState<RfqItem[]>([]) const [statistics, setStatistics] = React.useState<ItemStatistics | null>(null) const [isLoading, setIsLoading] = React.useState(false) @@ -182,7 +193,12 @@ export function RfqItemsDialog({ isOpen, onClose, rfqData }: RfqItemsDialogProps try { toast.loading(`POS 파일 다운로드 준비 중...`, { id: `download-${fileIndex}` }) - const downloadUrl = `/api/pos/download-on-demand?materialCode=${encodeURIComponent(selectedMaterialCode)}&fileIndex=${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') @@ -220,15 +236,11 @@ export function RfqItemsDialog({ isOpen, onClose, rfqData }: RfqItemsDialogProps {/* 통계 정보 */} {statistics && !isLoading && ( <> -<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 gap-3 py-3"> -<div className="text-center"> + <div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 gap-3 py-3"> + <div className="text-center"> <div className="text-2xl font-bold text-primary">{statistics.total}</div> <div className="text-xs text-muted-foreground">전체 품목</div> </div> - {/* <div className="text-center"> - <div className="text-2xl font-bold text-blue-600">{statistics.major}</div> - <div className="text-xs text-muted-foreground">주요 품목</div> - </div> */} <div className="text-center"> <div className="text-2xl font-bold text-gray-600">{statistics.regular}</div> <div className="text-xs text-muted-foreground">일반 품목</div> @@ -278,8 +290,6 @@ export function RfqItemsDialog({ isOpen, onClose, rfqData }: RfqItemsDialogProps <TableCell><Skeleton className="h-8 w-full" /></TableCell> <TableCell><Skeleton className="h-8 w-full" /></TableCell> <TableCell><Skeleton className="h-8 w-full" /></TableCell> - <TableCell><Skeleton className="h-8 w-full" /></TableCell> - <TableCell><Skeleton className="h-8 w-full" /></TableCell> </TableRow> ))} </TableBody> @@ -453,8 +463,7 @@ export function RfqItemsDialog({ isOpen, onClose, rfqData }: RfqItemsDialogProps <div className="border-t pt-3 text-xs text-muted-foreground"> <div className="flex justify-between items-center"> <span> - 총 {statistics.total}개 품목 - {/* (주요: {statistics.major}개, 일반: {statistics.regular}개) */} + 총 {statistics.total}개 품목 </span> <span> 전체 수량: {statistics.totalQuantity.toLocaleString()} | @@ -476,4 +485,5 @@ export function RfqItemsDialog({ isOpen, onClose, rfqData }: RfqItemsDialogProps </DialogContent> </Dialog> ) -}
\ 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<ReturnType<typeof getRfqs>>; @@ -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 </div> </div> </div> - - {/* 통계 정보 */} - <div className="mt-4"> - <div className="grid grid-cols-2 sm:grid-cols-4 gap-3"> - <div className="text-center p-3 border rounded-lg bg-muted/50"> - <div className="text-2xl font-bold text-primary">{statistics.total}</div> - <div className="text-xs text-muted-foreground">전체 품목</div> - </div> - <div className="text-center p-3 border rounded-lg bg-muted/50"> - <div className="text-2xl font-bold text-gray-600">{statistics.regular}</div> - <div className="text-xs text-muted-foreground">일반 품목</div> - </div> - <div className="text-center p-3 border rounded-lg bg-muted/50"> - <div className="text-2xl font-bold text-green-600">{statistics.totalQuantity.toLocaleString()}</div> - <div className="text-xs text-muted-foreground">총 수량</div> - </div> - <div className="text-center p-3 border rounded-lg bg-muted/50"> - <div className="text-2xl font-bold text-orange-600">{statistics.totalWeight.toLocaleString()}</div> - <div className="text-xs text-muted-foreground">총 중량 (KG)</div> - </div> - </div> - <Separator className="mt-4" /> - </div> </CardHeader> <CardContent> <ScrollArea className="h-[600px]"> 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<RfqItem[]>([]) - const [statistics, setStatistics] = React.useState<ItemStatistics | null>(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 ( - <Dialog open={isOpen} onOpenChange={onClose}> - <DialogContent className="max-w-7xl h-[90vh] flex flex-col"> - <DialogHeader> - <DialogTitle>견적 품목 목록</DialogTitle> - <DialogDescription> - {rfqData.rfqCode} - {rfqData.rfqTitle || rfqData.itemName || "품목 정보"} - </DialogDescription> - </DialogHeader> - - {/* 통계 정보 */} - {statistics && !isLoading && ( - <> -<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-5 gap-3 py-3"> -<div className="text-center"> - <div className="text-2xl font-bold text-primary">{statistics.total}</div> - <div className="text-xs text-muted-foreground">전체 품목</div> - </div> - <div className="text-center"> - <div className="text-2xl font-bold text-blue-600">{statistics.major}</div> - <div className="text-xs text-muted-foreground">주요 품목</div> - </div> - <div className="text-center"> - <div className="text-2xl font-bold text-gray-600">{statistics.regular}</div> - <div className="text-xs text-muted-foreground">일반 품목</div> - </div> - <div className="text-center"> - <div className="text-2xl font-bold text-green-600">{statistics.totalQuantity.toLocaleString()}</div> - <div className="text-xs text-muted-foreground">총 수량</div> - </div> - <div className="text-center"> - <div className="text-2xl font-bold text-orange-600">{statistics.totalWeight.toLocaleString()}</div> - <div className="text-xs text-muted-foreground">총 중량 (KG)</div> - </div> - </div> - <Separator /> - </> - )} - - <ScrollArea className="flex-1"> - {isLoading ? ( - <Table> - <TableHeader> - <TableRow> - <TableHead className="w-[60px]">구분</TableHead> - <TableHead className="w-[120px]">자재코드</TableHead> - <TableHead>자재명</TableHead> - <TableHead className="w-[80px]">수량</TableHead> - <TableHead className="w-[60px]">수량단위</TableHead> - <TableHead className="w-[80px]">중량</TableHead> - <TableHead className="w-[60px]">중량단위</TableHead> - <TableHead className="w-[100px]">납기일</TableHead> - <TableHead className="w-[100px]">PR번호</TableHead> - <TableHead className="w-[80px]">사양</TableHead> - <TableHead>비고</TableHead> - </TableRow> - </TableHeader> - <TableBody> - {[...Array(3)].map((_, i) => ( - <TableRow key={i}> - <TableCell><Skeleton className="h-8 w-full" /></TableCell> - <TableCell><Skeleton className="h-8 w-full" /></TableCell> - <TableCell><Skeleton className="h-8 w-full" /></TableCell> - <TableCell><Skeleton className="h-8 w-full" /></TableCell> - <TableCell><Skeleton className="h-8 w-full" /></TableCell> - <TableCell><Skeleton className="h-8 w-full" /></TableCell> - <TableCell><Skeleton className="h-8 w-full" /></TableCell> - <TableCell><Skeleton className="h-8 w-full" /></TableCell> - <TableCell><Skeleton className="h-8 w-full" /></TableCell> - <TableCell><Skeleton className="h-8 w-full" /></TableCell> - <TableCell><Skeleton className="h-8 w-full" /></TableCell> - <TableCell><Skeleton className="h-8 w-full" /></TableCell> - </TableRow> - ))} - </TableBody> - </Table> - ) : items.length === 0 ? ( - <div className="text-center text-muted-foreground py-12"> - <Package className="h-12 w-12 mx-auto mb-4 text-muted-foreground" /> - <p>품목이 없습니다.</p> - </div> - ) : ( - <Table> - <TableHeader> - <TableRow> - <TableHead className="w-[60px]">구분</TableHead> - <TableHead className="w-[120px]">자재코드</TableHead> - <TableHead>자재명</TableHead> - <TableHead className="w-[80px]">수량</TableHead> - <TableHead className="w-[60px]">수량단위</TableHead> - <TableHead className="w-[80px]">중량</TableHead> - <TableHead className="w-[60px]">중량단위</TableHead> - <TableHead className="w-[100px]">납기일</TableHead> - <TableHead className="w-[100px]">PR번호</TableHead> - <TableHead className="w-[100px]">사양</TableHead> - <TableHead className="w-[100px]">프로젝트</TableHead> - <TableHead>비고</TableHead> - </TableRow> - </TableHeader> - <TableBody> - {items.map((item, index) => ( - <TableRow key={item.id} className={item.majorYn ? "bg-blue-50 border-l-4 border-l-blue-500" : ""}> - <TableCell> - <div className="flex flex-col items-center gap-1"> - <span className="text-xs font-mono">#{index + 1}</span> - {item.majorYn && ( - <Badge variant="default" className="text-xs px-1 py-0"> - 주요 - </Badge> - )} - </div> - </TableCell> - <TableCell> - <div className="flex flex-col"> - <span className="font-mono text-sm font-medium">{item.materialCode || "-"}</span> - {item.acc && ( - <span className="text-xs text-muted-foreground font-mono"> - ACC: {item.acc} - </span> - )} - </div> - </TableCell> - <TableCell> - <div className="flex flex-col"> - <span className="text-sm font-medium" title={item.materialDescription || ""}> - {item.materialDescription || "-"} - </span> - {item.materialCategory && ( - <span className="text-xs text-muted-foreground"> - {item.materialCategory} - </span> - )} - {item.size && ( - <span className="text-xs text-muted-foreground"> - 크기: {item.size} - </span> - )} - </div> - </TableCell> - <TableCell> - <span className="text-sm font-medium"> - {item.quantity ? item.quantity.toLocaleString() : "-"} - </span> - </TableCell> - <TableCell> - <span className="text-sm text-muted-foreground"> - {item.uom || "-"} - </span> - </TableCell> - <TableCell> - <span className="text-sm font-medium"> - {item.grossWeight ? item.grossWeight.toLocaleString() : "-"} - </span> - </TableCell> - <TableCell> - <span className="text-sm text-muted-foreground"> - {item.gwUom || "-"} - </span> - </TableCell> - <TableCell> - <span className="text-sm"> - {item.deliveryDate ? format(new Date(item.deliveryDate), "yyyy-MM-dd") : "-"} - </span> - </TableCell> - <TableCell> - <div className="flex flex-col"> - <span className="text-xs font-mono">{item.prNo || "-"}</span> - {item.prItem && item.prItem !== item.prNo && ( - <span className="text-xs text-muted-foreground font-mono"> - {item.prItem} - </span> - )} - </div> - </TableCell> - <TableCell> - <div className="flex items-center gap-1"> - {item.specNo && ( - <span className="text-xs font-mono">{item.specNo}</span> - )} - {item.specUrl && ( - <Button - variant="ghost" - size="sm" - className="h-5 w-5 p-0" - onClick={() => handleOpenSpec(item.specUrl!)} - title="사양서 열기" - > - <ExternalLink className="h-3 w-3" /> - </Button> - )} - {item.trackingNo && ( - <div className="text-xs text-muted-foreground"> - TRK: {item.trackingNo} - </div> - )} - </div> - </TableCell> - <TableCell> - <div className="text-xs"> - {[ - 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(" | ") || "-"} - </div> - </TableCell> - <TableCell> - <span className="text-xs" title={item.remark || ""}> - {item.remark ? (item.remark.length > 30 ? `${item.remark.slice(0, 30)}...` : item.remark) : "-"} - </span> - </TableCell> - </TableRow> - ))} - </TableBody> - </Table> - )} - </ScrollArea> - - {/* 하단 통계 정보 */} - {statistics && !isLoading && ( - <div className="border-t pt-3 text-xs text-muted-foreground"> - <div className="flex justify-between items-center"> - <span> - 총 {statistics.total}개 품목 - (주요: {statistics.major}개, 일반: {statistics.regular}개) - </span> - <span> - 전체 수량: {statistics.totalQuantity.toLocaleString()} | - 전체 중량: {statistics.totalWeight.toLocaleString()} KG - </span> - </div> - </div> - )} - </DialogContent> - </Dialog> - ) -}
\ 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<DocumentListItem>[] = [ }, { id: "actions", - header: "액션", + header: "커버페이지 다운로드", cell: function ActionCell({ row }) { const [isDownloading, setIsDownloading] = React.useState(false); |
