summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorjoonhoekim <26rote@gmail.com>2025-11-04 17:30:27 +0900
committerjoonhoekim <26rote@gmail.com>2025-11-04 17:30:27 +0900
commita1710296dbc1881a7ed86093872904a529901430 (patch)
tree62e4be5dabba4bf7c337769f391d11ce59154cc4
parent680da9b323db8b8d7cf27c674ab0016ec87bfe81 (diff)
(김준회) RFQ 테이블 POS 다운로드할 수 있도록 변경, 벤더측은 다운로드시 암호화 해제 추가, item dialog 통일처리
-rw-r--r--app/api/pos/download-on-demand-partners/route.ts243
-rw-r--r--lib/pos/components/pos-file-selection-dialog.tsx7
-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.tsx3
-rw-r--r--lib/rfq-last/vendor-response/editor/quotation-items-table.tsx32
-rw-r--r--lib/rfq-last/vendor-response/rfq-items-dialog.tsx360
-rw-r--r--lib/rfq-last/vendor-response/vendor-quotations-table.tsx3
-rw-r--r--lib/swp/table/swp-table-columns.tsx2
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);