diff options
| author | dujinkim <dujin.kim@dtsolution.co.kr> | 2025-10-17 08:08:33 +0000 |
|---|---|---|
| committer | dujinkim <dujin.kim@dtsolution.co.kr> | 2025-10-17 08:08:33 +0000 |
| commit | 1540eac291761ffd8fc1947ed626e4e4a4407922 (patch) | |
| tree | a7b6ae8060e164f249651cf6ef8b0c2e868019e9 | |
| parent | 55b6153dfce83a1cf2be72cbc3413d78084e8da1 (diff) | |
(최겸) 견적입찰 비교관련 수정
| -rw-r--r-- | app/[lng]/evcp/(evcp)/(procurement)/rfq-last/[id]/compare/page.tsx | 6 | ||||
| -rw-r--r-- | app/api/partners/rfq-last/[id]/response/route.ts | 74 | ||||
| -rw-r--r-- | lib/rfq-last/compare-action.ts | 236 | ||||
| -rw-r--r-- | lib/rfq-last/quotation-compare-view.tsx | 466 | ||||
| -rw-r--r-- | lib/rfq-last/vendor-response/editor/commercial-terms-form.tsx | 7 | ||||
| -rw-r--r-- | lib/rfq-last/vendor/rfq-vendor-table.tsx | 6 |
6 files changed, 519 insertions, 276 deletions
diff --git a/app/[lng]/evcp/(evcp)/(procurement)/rfq-last/[id]/compare/page.tsx b/app/[lng]/evcp/(evcp)/(procurement)/rfq-last/[id]/compare/page.tsx index 097b99eb..461a0863 100644 --- a/app/[lng]/evcp/(evcp)/(procurement)/rfq-last/[id]/compare/page.tsx +++ b/app/[lng]/evcp/(evcp)/(procurement)/rfq-last/[id]/compare/page.tsx @@ -28,9 +28,9 @@ export default async function ComparePage({ .map(id => parseInt(id)) .filter(id => !isNaN(id)) || []; - if (!rfqId || vendorIds.length < 2) { - notFound(); - } + // if (!rfqId || vendorIds.length < 2) { + // notFound(); + // } // 서버에서 데이터 가져오기 const data = await getComparisonData(rfqId, vendorIds); diff --git a/app/api/partners/rfq-last/[id]/response/route.ts b/app/api/partners/rfq-last/[id]/response/route.ts index ebcccd8f..5d05db50 100644 --- a/app/api/partners/rfq-last/[id]/response/route.ts +++ b/app/api/partners/rfq-last/[id]/response/route.ts @@ -264,42 +264,57 @@ export async function PUT( // 2. 새 버전 생성 (제출 시) 또는 기존 버전 업데이트 let responseId = existingResponse.id - if (data.status === "제출완료" && previousStatus !== "제출완료") { - // 기존 버전을 비활성화 - await tx.update(rfqLastVendorResponses) - .set({ isLatest: false }) - .where(eq(rfqLastVendorResponses.id, existingResponse.id)) + // if (data.status === "제출완료" && previousStatus !== "제출완료") { + // // 기존 버전을 비활성화 + // await tx.update(rfqLastVendorResponses) + // .set({ isLatest: false }) + // .where(eq(rfqLastVendorResponses.id, existingResponse.id)) - // 기존 첨부파일을 새 응답으로 이동 - if (existingResponse.id) { - await tx.update(rfqLastVendorAttachments) - .set({ vendorResponseId: existingResponse.id }) // 임시로 기존 응답에 연결 - .where(eq(rfqLastVendorAttachments.vendorResponseId, existingResponse.id)) - } + // // 기존 첨부파일을 새 응답으로 이동 + // if (existingResponse.id) { + // await tx.update(rfqLastVendorAttachments) + // .set({ vendorResponseId: existingResponse.id }) // 기존 응답에 연결 + // .where(eq(rfqLastVendorAttachments.vendorResponseId, existingResponse.id)) + // } + + // // 새 버전 생성 + // const [newResponse] = await tx.insert(rfqLastVendorResponses).values({ + // ...data, + // vendorDeliveryDate: data.vendorDeliveryDate ? new Date(data.vendorDeliveryDate) : null, + // submittedAt: data.submittedAt ? new Date(data.submittedAt) : null, + // responseVersion: existingResponse.responseVersion + 1, + // status:"제출완료", + // participationStatus: "참여", + // participationRepliedAt: existingResponse.participationRepliedAt ? new Date() : null, + // participationRepliedBy: existingResponse.participationRepliedBy ? session.user.id : null, + + // isLatest: true, + // createdBy: existingResponse.createdBy, + // updatedBy: session.user.id, + // }).returning() + + // // 기존 첨부파일을 새 응답으로 이동 + // if (newResponse.id) { + // await tx.update(rfqLastVendorAttachments) + // .set({ vendorResponseId: newResponse.id }) + // .where(eq(rfqLastVendorAttachments.vendorResponseId, existingResponse.id)) + // } - // 새 버전 생성 - const [newResponse] = await tx.insert(rfqLastVendorResponses).values({ + // responseId = newResponse.id + // } else { + // // 기존 버전 업데이트 + if(data.status === "제출완료") { + await tx.update(rfqLastVendorResponses) + .set({ ...data, vendorDeliveryDate: data.vendorDeliveryDate ? new Date(data.vendorDeliveryDate) : null, submittedAt: data.submittedAt ? new Date(data.submittedAt) : null, - responseVersion: existingResponse.responseVersion + 1, - status:"제출완료", - participationStatus: "참여", - isLatest: true, - createdBy: existingResponse.createdBy, + status: data.status, updatedBy: session.user.id, - }).returning() - - // 기존 첨부파일을 새 응답으로 이동 - if (newResponse.id) { - await tx.update(rfqLastVendorAttachments) - .set({ vendorResponseId: newResponse.id }) - .where(eq(rfqLastVendorAttachments.vendorResponseId, existingResponse.id)) - } - - responseId = newResponse.id + updatedAt: new Date(), + }) + .where(eq(rfqLastVendorResponses.id, existingResponse.id)) } else { - // 기존 버전 업데이트 await tx.update(rfqLastVendorResponses) .set({ ...data, @@ -311,6 +326,7 @@ export async function PUT( .where(eq(rfqLastVendorResponses.id, existingResponse.id)) } + // 3. 견적 아이템 업데이트 // 기존 아이템 삭제 await tx.delete(rfqLastVendorQuotationItems) diff --git a/lib/rfq-last/compare-action.ts b/lib/rfq-last/compare-action.ts index 1a50a373..57f8f00f 100644 --- a/lib/rfq-last/compare-action.ts +++ b/lib/rfq-last/compare-action.ts @@ -1,17 +1,17 @@ "use server"; import db from "@/db/db"; -import { eq, and, inArray, ne, asc, desc } from "drizzle-orm"; +import { eq, and, inArray, ne, asc, isNotNull } from "drizzle-orm"; import { rfqsLast, rfqLastDetails, rfqPrItems, rfqLastVendorResponses, rfqLastVendorQuotationItems, + rfqLastVendorAttachments, vendors, paymentTerms, incoterms, - vendorSelections, users } from "@/db/schema"; import { revalidatePath } from "next/cache"; @@ -62,22 +62,22 @@ export interface VendorResponseVersion { // 벤더 제안 조건 vendorConditions: { - currency?: string; - paymentTermsCode?: string; - paymentTermsDesc?: string; - incotermsCode?: string; - incotermsDesc?: string; + currency?: string | null; + paymentTermsCode?: string | null; + paymentTermsDesc?: string | null; + incotermsCode?: string | null; + incotermsDesc?: string | null; deliveryDate?: Date | null; - contractDuration?: string; - taxCode?: string; - placeOfShipping?: string; - placeOfDestination?: string; - firstAcceptance?: "수용" | "부분수용" | "거부"; - firstDescription?: string; - sparepartAcceptance?: "수용" | "부분수용" | "거부"; - sparepartDescription?: string; - materialPriceRelatedYn?: boolean; - materialPriceRelatedReason?: string; + contractDuration?: string | null; + taxCode?: string | null; + placeOfShipping?: string | null; + placeOfDestination?: string | null; + firstAcceptance?: "수용" | "부분수용" | "거부" | null; + firstDescription?: string | null; + sparepartAcceptance?: "수용" | "부분수용" | "거부" | null; + sparepartDescription?: string | null; + materialPriceRelatedYn?: boolean | null; + materialPriceRelatedReason?: string | null; }; // 조건 차이 분석 @@ -88,8 +88,8 @@ export interface VendorResponseVersion { }; // 비고 - generalRemark?: string; - technicalProposal?: string; + generalRemark?: string | null; + technicalProposal?: string | null; // 품목별 견적 아이템 정보 추가 quotationItems?: { @@ -98,11 +98,14 @@ export interface VendorResponseVersion { totalPrice: number; currency: string; quantity: number; - deliveryDate?: Date | null; - leadTime?: number; - manufacturer?: string; - modelNo?: string; + deliveryDate: Date | null | undefined; + leadTime: number | null | undefined; + manufacturer: string | null | undefined; + modelNo: string | null | undefined; }[]; + + // 첨부파일 정보 + attachments?: VendorAttachment[]; } export interface VendorComparison { @@ -119,14 +122,14 @@ export interface VendorComparison { incotermsCode: string; incotermsDesc?: string; deliveryDate: Date | null; - contractDuration?: string; - taxCode?: string; - placeOfShipping?: string; - placeOfDestination?: string; + contractDuration?: string | null; + taxCode?: string | null; + placeOfShipping?: string | null; + placeOfDestination?: string | null; firstYn: boolean; - firstDescription?: string; + firstDescription?: string | null; sparepartYn: boolean; - sparepartDescription?: string; + sparepartDescription?: string | null; materialPriceRelatedYn: boolean; }; @@ -150,22 +153,22 @@ export interface VendorComparison { // 레거시 호환: 최신 응답의 조건 정보 vendorConditions: { - currency?: string; - paymentTermsCode?: string; - paymentTermsDesc?: string; - incotermsCode?: string; - incotermsDesc?: string; + currency?: string | null; + paymentTermsCode?: string | null; + paymentTermsDesc?: string | null; + incotermsCode?: string | null; + incotermsDesc?: string | null; deliveryDate?: Date | null; - contractDuration?: string; - taxCode?: string; - placeOfShipping?: string; - placeOfDestination?: string; - firstAcceptance?: "수용" | "부분수용" | "거부"; - firstDescription?: string; - sparepartAcceptance?: "수용" | "부분수용" | "거부"; - sparepartDescription?: string; - materialPriceRelatedYn?: boolean; - materialPriceRelatedReason?: string; + contractDuration?: string | null; + taxCode?: string | null; + placeOfShipping?: string | null; + placeOfDestination?: string | null; + firstAcceptance?: "수용" | "부분수용" | "거부" | null; + firstDescription?: string | null; + sparepartAcceptance?: "수용" | "부분수용" | "거부" | null; + sparepartDescription?: string | null; + materialPriceRelatedYn?: boolean | null; + materialPriceRelatedReason?: string | null; }; // 레거시 호환: 최신 응답의 조건 차이 분석 @@ -176,23 +179,23 @@ export interface VendorComparison { }; // 레거시 호환: 최신 응답의 비고 - generalRemark?: string; - technicalProposal?: string; + generalRemark?: string | null; + technicalProposal?: string | null; // 선정 관련 정보 isSelected?: boolean; selectionDate?: Date | null; - selectionReason?: string; - selectedBy?: number; + selectionReason?: string | null; + selectedBy?: number | null; selectedByName?: string; selectionApprovalStatus?: "대기" | "승인" | "반려" | null; - selectionApprovedBy?: number; + selectionApprovedBy?: number | null; selectionApprovedAt?: Date | null; - selectionApprovalComment?: string; + selectionApprovalComment?: string | null; // 계약 관련 정보 추가 - contractStatus?: string; - contractNo?: string; + contractStatus?: string | null; + contractNo?: string | null; contractCreatedAt?: Date | null; } @@ -202,9 +205,14 @@ export interface PrItemComparison { prItem: string; materialCode: string; materialDescription: string; + materialCategory?: string; requestedQuantity: number; uom: string; + size?: string; + grossWeight?: number; + gwUom?: string; requestedDeliveryDate: Date | null; + remark?: string; vendorQuotes: { vendorId: number; @@ -231,6 +239,23 @@ export interface PrItemComparison { }; } +export interface VendorAttachment { + id: number; + attachmentType: string; + documentNo?: string; + fileName: string; + originalFileName: string; + filePath: string; + fileSize?: number; + fileType?: string; + description?: string; + validFrom?: Date | null; + validTo?: Date | null; + uploadedBy: number; + uploadedAt: Date; + uploaderName?: string; +} + // ===== 메인 조회 함수 ===== export async function getComparisonData( @@ -307,7 +332,7 @@ export async function getComparisonData( ) .where(inArray(vendors.id, vendorIds)); - // 2b. 모든 차수의 벤더 응답 조회 (isLatest 조건 제거) + // 2b. 벤더가 실제 제출한 응답만 조회 (submittedAt이 null이 아닌 것만) const allVendorResponses = await db .select({ responseId: rfqLastVendorResponses.id, @@ -347,7 +372,8 @@ export async function getComparisonData( .where( and( eq(rfqLastVendorResponses.rfqsLastId, rfqId), - inArray(rfqLastVendorResponses.vendorId, vendorIds) + inArray(rfqLastVendorResponses.vendorId, vendorIds), + isNotNull(rfqLastVendorResponses.submittedAt) // 벤더가 실제 제출한 것만 ) ) .orderBy(asc(rfqLastVendorResponses.vendorId), asc(rfqLastVendorResponses.responseVersion)); @@ -386,7 +412,7 @@ export async function getComparisonData( incotermsData.map(ic => [ic.code, ic.description]) ); - // 5. PR Items 조회 + // 5. PR Items 조회 (추가 필드 포함) const prItems = await db .select({ id: rfqPrItems.id, @@ -394,9 +420,14 @@ export async function getComparisonData( prItem: rfqPrItems.prItem, materialCode: rfqPrItems.materialCode, materialDescription: rfqPrItems.materialDescription, + materialCategory: rfqPrItems.materialCategory, quantity: rfqPrItems.quantity, uom: rfqPrItems.uom, + size: rfqPrItems.size, + grossWeight: rfqPrItems.grossWeight, + gwUom: rfqPrItems.gwUom, deliveryDate: rfqPrItems.deliveryDate, + remark: rfqPrItems.remark, }) .from(rfqPrItems) .where(eq(rfqPrItems.rfqsLastId, rfqId)); @@ -424,6 +455,31 @@ export async function getComparisonData( .where(inArray(rfqLastVendorQuotationItems.vendorResponseId, allResponseIds)) : []; + // 6b. 벤더 첨부파일 조회 (모든 응답 버전 포함) + const vendorAttachments = allResponseIds.length > 0 + ? await db + .select({ + id: rfqLastVendorAttachments.id, + vendorResponseId: rfqLastVendorAttachments.vendorResponseId, + attachmentType: rfqLastVendorAttachments.attachmentType, + documentNo: rfqLastVendorAttachments.documentNo, + fileName: rfqLastVendorAttachments.fileName, + originalFileName: rfqLastVendorAttachments.originalFileName, + filePath: rfqLastVendorAttachments.filePath, + fileSize: rfqLastVendorAttachments.fileSize, + fileType: rfqLastVendorAttachments.fileType, + description: rfqLastVendorAttachments.description, + validFrom: rfqLastVendorAttachments.validFrom, + validTo: rfqLastVendorAttachments.validTo, + uploadedBy: rfqLastVendorAttachments.uploadedBy, + uploadedAt: rfqLastVendorAttachments.uploadedAt, + uploaderName: users.name, + }) + .from(rfqLastVendorAttachments) + .leftJoin(users, eq(rfqLastVendorAttachments.uploadedBy, users.id)) + .where(inArray(rfqLastVendorAttachments.vendorResponseId, allResponseIds)) + : []; + // 7. 데이터 가공 및 분석 - 각 벤더별 최신 차수 기준으로 평균 계산 // 각 벤더별로 가장 높은 responseVersion을 가진 응답 찾기 const latestResponsesByVendor = new Map<number, typeof allVendorResponses[0]>(); @@ -454,7 +510,7 @@ export async function getComparisonData( : 0; // 8. 벤더별 비교 데이터 구성 (차수별 응답 포함) - const vendorComparisons: VendorComparison[] = vendorData.map((v) => { + const vendorComparisons = vendorData.map((v) => { // 이 벤더의 모든 응답 가져오기 const vendorResponses = allVendorResponses.filter(r => r.vendorId === v.vendorId); @@ -501,10 +557,30 @@ export async function getComparisonData( totalPrice: q.totalPrice || 0, currency: q.currency || "USD", quantity: q.quantity || 0, - deliveryDate: q.deliveryDate, - leadTime: q.leadTime, - manufacturer: q.manufacturer, - modelNo: q.modelNo, + deliveryDate: q.deliveryDate || undefined, + leadTime: q.leadTime || undefined, + manufacturer: q.manufacturer || undefined, + modelNo: q.modelNo || undefined, + })); + + // 이 응답의 첨부파일 가져오기 + const responseAttachments = vendorAttachments + .filter(a => a.vendorResponseId === resp.responseId) + .map(a => ({ + id: a.id, + attachmentType: a.attachmentType, + documentNo: a.documentNo || undefined, + fileName: a.fileName, + originalFileName: a.originalFileName, + filePath: a.filePath, + fileSize: a.fileSize || undefined, + fileType: a.fileType || undefined, + description: a.description || undefined, + validFrom: a.validFrom || undefined, + validTo: a.validTo || undefined, + uploadedBy: a.uploadedBy, + uploadedAt: a.uploadedAt, + uploaderName: a.uploaderName || undefined, })); return { @@ -544,9 +620,10 @@ export async function getComparisonData( criticalDifferences, }, - generalRemark: resp.generalRemark, - technicalProposal: resp.technicalProposal, + generalRemark: resp.generalRemark || undefined, + technicalProposal: resp.technicalProposal || undefined, quotationItems: responseQuotationItems, + attachments: responseAttachments, }; }); @@ -567,14 +644,14 @@ export async function getComparisonData( incotermsCode: v.buyerIncotermsCode || "", incotermsDesc: incotermsMap.get(v.buyerIncotermsCode || ""), deliveryDate: v.buyerDeliveryDate, - contractDuration: v.buyerContractDuration, - taxCode: v.buyerTaxCode, - placeOfShipping: v.buyerPlaceOfShipping, - placeOfDestination: v.buyerPlaceOfDestination, + contractDuration: v.buyerContractDuration || undefined, + taxCode: v.buyerTaxCode || undefined, + placeOfShipping: v.buyerPlaceOfShipping || undefined, + placeOfDestination: v.buyerPlaceOfDestination || undefined, firstYn: v.buyerFirstYn || false, - firstDescription: v.buyerFirstDescription, + firstDescription: v.buyerFirstDescription || undefined, sparepartYn: v.buyerSparepartYn || false, - sparepartDescription: v.buyerSparepartDescription, + sparepartDescription: v.buyerSparepartDescription || undefined, materialPriceRelatedYn: v.buyerMaterialPriceRelatedYn || false, }, @@ -618,23 +695,23 @@ export async function getComparisonData( criticalDifferences: [], }, - generalRemark: latestResp?.generalRemark, - technicalProposal: latestResp?.technicalProposal, + generalRemark: latestResp?.generalRemark || undefined, + technicalProposal: latestResp?.technicalProposal || undefined, // 선정 관련 정보 isSelected: v.isSelected || false, selectionDate: v.selectionDate, - selectionReason: v.selectionReason, - selectedBy: v.selectedBy, + selectionReason: v.selectionReason || undefined, + selectedBy: v.selectedBy || undefined, selectedByName: v.isSelected ? selectedByName : undefined, - selectionApprovalStatus: v.selectionApprovalStatus, - selectionApprovedBy: v.selectionApprovedBy, + selectionApprovalStatus: v.selectionApprovalStatus || undefined, + selectionApprovedBy: v.selectionApprovedBy || undefined, selectionApprovedAt: v.selectionApprovedAt, - selectionApprovalComment: v.selectionApprovalComment, + selectionApprovalComment: v.selectionApprovalComment || undefined, // 계약 관련 정보 - contractStatus: v.contractStatus, - contractNo: v.contractNo, + contractStatus: v.contractStatus || undefined, + contractNo: v.contractNo || undefined, contractCreatedAt: v.contractCreatedAt, }; }); @@ -690,9 +767,14 @@ export async function getComparisonData( prItem: item.prItem || "", materialCode: item.materialCode || "", materialDescription: item.materialDescription || "", + materialCategory: item.materialCategory || undefined, requestedQuantity: item.quantity || 0, uom: item.uom || "", + size: item.size || undefined, + grossWeight: item.grossWeight || undefined, + gwUom: item.gwUom || undefined, requestedDeliveryDate: item.deliveryDate, + remark: item.remark || undefined, vendorQuotes: itemQuotes, priceAnalysis: { lowestPrice: Math.min(...unitPrices) || 0, diff --git a/lib/rfq-last/quotation-compare-view.tsx b/lib/rfq-last/quotation-compare-view.tsx index 723d1044..527fc4d8 100644 --- a/lib/rfq-last/quotation-compare-view.tsx +++ b/lib/rfq-last/quotation-compare-view.tsx @@ -28,6 +28,8 @@ import { X, RefreshCw, Clock, + Download, + Paperclip, } from "lucide-react"; import { cn } from "@/lib/utils"; import { format } from "date-fns"; @@ -193,13 +195,14 @@ export function QuotationCompareView({ data }: QuotationCompareViewProps) { switch (selectedContractType) { case "PO": + const poSelectionReason: string | undefined = selectedVendor.selectionReason || undefined; result = await createPO({ rfqId: data.rfqInfo.id, vendorId: selectedVendor.vendorId, vendorName: selectedVendor.vendorName, totalAmount: selectedVendor.totalAmount, currency: selectedVendor.currency, - selectionReason: selectedVendor.selectionReason, + selectionReason: poSelectionReason, }); break; @@ -712,18 +715,29 @@ export function QuotationCompareView({ data }: QuotationCompareViewProps) { <table className="w-full"> <thead> <tr className="border-b"> - <th className="text-left p-3 font-semibold">항목</th> - <th className="text-left p-3 font-semibold">구매자 제시</th> + <th className="text-left p-3 font-semibold" rowSpan={2}>항목</th> + </tr> + <tr className="border-b bg-gray-50"> {data.vendors.map((vendor) => ( - <th key={vendor.vendorId} className={cn( - "text-left p-3 font-semibold", - vendor.isSelected && "bg-blue-50" - )}> - {vendor.vendorName} + <React.Fragment key={vendor.vendorId}> + <th className={cn( + "text-center p-2 text-xs font-medium border-r", + vendor.isSelected && "bg-blue-50" + )}> + SHI 제시 + </th> + <th className={cn( + "text-center p-2 text-xs font-medium", + vendor.isSelected && "bg-blue-50" + )}> + <div className="font-bold"> + {vendor.vendorName} + </div> {vendor.isSelected && ( <Badge className="ml-2 bg-blue-600 text-xs">선정</Badge> )} - </th> + </th> + </React.Fragment> ))} </tr> </thead> @@ -731,21 +745,28 @@ export function QuotationCompareView({ data }: QuotationCompareViewProps) { {/* 통화 */} <tr> <td className="p-3 font-medium">통화</td> - <td className="p-3">{data.vendors[0]?.buyerConditions.currency}</td> {data.vendors.map((vendor) => { const latestResponse = vendor.responses[0]; // 최신 응답 (이미 정렬되어 있음) return ( - <td key={vendor.vendorId} className={cn( - "p-3", - vendor.isSelected && "bg-blue-50" - )}> - <div className="flex items-center gap-2"> - {latestResponse?.vendorConditions?.currency || vendor.buyerConditions.currency} - {latestResponse?.vendorConditions?.currency && latestResponse.vendorConditions.currency !== vendor.buyerConditions.currency && ( - <Badge variant="destructive" className="text-xs">변경</Badge> - )} - </div> - </td> + <React.Fragment key={vendor.vendorId}> + <td className={cn( + "p-3 text-center border-r", + vendor.isSelected && "bg-blue-50" + )}> + {vendor.buyerConditions.currency} + </td> + <td className={cn( + "p-3 text-center", + vendor.isSelected && "bg-blue-50" + )}> + <div className="flex items-center justify-center gap-2"> + {latestResponse?.vendorConditions?.currency || vendor.buyerConditions.currency} + {latestResponse?.vendorConditions?.currency && latestResponse.vendorConditions.currency !== vendor.buyerConditions.currency && ( + <Badge variant="destructive" className="text-xs">변경</Badge> + )} + </div> + </td> + </React.Fragment> ); })} </tr> @@ -753,42 +774,47 @@ export function QuotationCompareView({ data }: QuotationCompareViewProps) { {/* 지급조건 */} <tr> <td className="p-3 font-medium">지급조건</td> - <td className="p-3"> - <TooltipProvider> - <Tooltip> - <TooltipTrigger className="cursor-help border-b border-dashed"> - {data.vendors[0]?.buyerConditions.paymentTermsCode} - </TooltipTrigger> - <TooltipContent> - {data.vendors[0]?.buyerConditions.paymentTermsDesc} - </TooltipContent> - </Tooltip> - </TooltipProvider> - </td> {data.vendors.map((vendor) => { const latestResponse = vendor.responses[0]; return ( - <td key={vendor.vendorId} className={cn( - "p-3", - vendor.isSelected && "bg-blue-50" - )}> - <div className="flex items-center gap-2"> + <React.Fragment key={vendor.vendorId}> + <td className={cn( + "p-3 text-center border-r", + vendor.isSelected && "bg-blue-50" + )}> <TooltipProvider> <Tooltip> <TooltipTrigger className="cursor-help border-b border-dashed"> - {latestResponse?.vendorConditions?.paymentTermsCode || vendor.buyerConditions.paymentTermsCode} + {vendor.buyerConditions.paymentTermsCode} </TooltipTrigger> <TooltipContent> - {latestResponse?.vendorConditions?.paymentTermsDesc || vendor.buyerConditions.paymentTermsDesc} + {vendor.buyerConditions.paymentTermsDesc} </TooltipContent> </Tooltip> </TooltipProvider> - {latestResponse?.vendorConditions?.paymentTermsCode && - latestResponse.vendorConditions.paymentTermsCode !== vendor.buyerConditions.paymentTermsCode && ( - <Badge variant="outline" className="text-xs">변경</Badge> - )} - </div> - </td> + </td> + <td className={cn( + "p-3 text-center", + vendor.isSelected && "bg-blue-50" + )}> + <div className="flex items-center justify-center gap-2"> + <TooltipProvider> + <Tooltip> + <TooltipTrigger className="cursor-help border-b border-dashed"> + {latestResponse?.vendorConditions?.paymentTermsCode || vendor.buyerConditions.paymentTermsCode} + </TooltipTrigger> + <TooltipContent> + {latestResponse?.vendorConditions?.paymentTermsDesc || vendor.buyerConditions.paymentTermsDesc} + </TooltipContent> + </Tooltip> + </TooltipProvider> + {latestResponse?.vendorConditions?.paymentTermsCode && + latestResponse.vendorConditions.paymentTermsCode !== vendor.buyerConditions.paymentTermsCode && ( + <Badge variant="outline" className="text-xs">변경</Badge> + )} + </div> + </td> + </React.Fragment> ); })} </tr> @@ -796,42 +822,47 @@ export function QuotationCompareView({ data }: QuotationCompareViewProps) { {/* 인코텀즈 */} <tr> <td className="p-3 font-medium">인코텀즈</td> - <td className="p-3"> - <TooltipProvider> - <Tooltip> - <TooltipTrigger className="cursor-help border-b border-dashed"> - {data.vendors[0]?.buyerConditions.incotermsCode} - </TooltipTrigger> - <TooltipContent> - {data.vendors[0]?.buyerConditions.incotermsDesc} - </TooltipContent> - </Tooltip> - </TooltipProvider> - </td> {data.vendors.map((vendor) => { const latestResponse = vendor.responses[0]; return ( - <td key={vendor.vendorId} className={cn( - "p-3", - vendor.isSelected && "bg-blue-50" - )}> - <div className="flex items-center gap-2"> + <React.Fragment key={vendor.vendorId}> + <td className={cn( + "p-3 text-center border-r", + vendor.isSelected && "bg-blue-50" + )}> <TooltipProvider> <Tooltip> <TooltipTrigger className="cursor-help border-b border-dashed"> - {latestResponse?.vendorConditions?.incotermsCode || vendor.buyerConditions.incotermsCode} + {vendor.buyerConditions.incotermsCode} </TooltipTrigger> <TooltipContent> - {latestResponse?.vendorConditions?.incotermsDesc || vendor.buyerConditions.incotermsDesc} + {vendor.buyerConditions.incotermsDesc} </TooltipContent> </Tooltip> </TooltipProvider> - {latestResponse?.vendorConditions?.incotermsCode && - latestResponse.vendorConditions.incotermsCode !== vendor.buyerConditions.incotermsCode && ( - <Badge variant="outline" className="text-xs">변경</Badge> - )} - </div> - </td> + </td> + <td className={cn( + "p-3 text-center", + vendor.isSelected && "bg-blue-50" + )}> + <div className="flex items-center justify-center gap-2"> + <TooltipProvider> + <Tooltip> + <TooltipTrigger className="cursor-help border-b border-dashed"> + {latestResponse?.vendorConditions?.incotermsCode || vendor.buyerConditions.incotermsCode} + </TooltipTrigger> + <TooltipContent> + {latestResponse?.vendorConditions?.incotermsDesc || vendor.buyerConditions.incotermsDesc} + </TooltipContent> + </Tooltip> + </TooltipProvider> + {latestResponse?.vendorConditions?.incotermsCode && + latestResponse.vendorConditions.incotermsCode !== vendor.buyerConditions.incotermsCode && ( + <Badge variant="outline" className="text-xs">변경</Badge> + )} + </div> + </td> + </React.Fragment> ); })} </tr> @@ -839,22 +870,29 @@ export function QuotationCompareView({ data }: QuotationCompareViewProps) { {/* 선적지 */} <tr> <td className="p-3 font-medium">선적지</td> - <td className="p-3">{data.vendors[0]?.buyerConditions.placeOfShipping || "-"}</td> {data.vendors.map((vendor) => { const latestResponse = vendor.responses[0]; return ( - <td key={vendor.vendorId} className={cn( - "p-3", - vendor.isSelected && "bg-blue-50" - )}> - <div className="flex items-center gap-2"> - {latestResponse?.vendorConditions?.placeOfShipping || vendor.buyerConditions.placeOfShipping || "-"} - {latestResponse?.vendorConditions?.placeOfShipping && - latestResponse.vendorConditions.placeOfShipping !== vendor.buyerConditions.placeOfShipping && ( - <Badge variant="outline" className="text-xs">변경</Badge> - )} - </div> - </td> + <React.Fragment key={vendor.vendorId}> + <td className={cn( + "p-3 text-center border-r", + vendor.isSelected && "bg-blue-50" + )}> + {vendor.buyerConditions.placeOfShipping || "-"} + </td> + <td className={cn( + "p-3 text-center", + vendor.isSelected && "bg-blue-50" + )}> + <div className="flex items-center justify-center gap-2"> + {latestResponse?.vendorConditions?.placeOfShipping || vendor.buyerConditions.placeOfShipping || "-"} + {latestResponse?.vendorConditions?.placeOfShipping && + latestResponse.vendorConditions.placeOfShipping !== vendor.buyerConditions.placeOfShipping && ( + <Badge variant="outline" className="text-xs">변경</Badge> + )} + </div> + </td> + </React.Fragment> ); })} </tr> @@ -862,22 +900,29 @@ export function QuotationCompareView({ data }: QuotationCompareViewProps) { {/* 하역지 */} <tr> <td className="p-3 font-medium">하역지</td> - <td className="p-3">{data.vendors[0]?.buyerConditions.placeOfDestination || "-"}</td> {data.vendors.map((vendor) => { const latestResponse = vendor.responses[0]; return ( - <td key={vendor.vendorId} className={cn( - "p-3", - vendor.isSelected && "bg-blue-50" - )}> - <div className="flex items-center gap-2"> - {latestResponse?.vendorConditions?.placeOfDestination || vendor.buyerConditions.placeOfDestination || "-"} - {latestResponse?.vendorConditions?.placeOfDestination && - latestResponse.vendorConditions.placeOfDestination !== vendor.buyerConditions.placeOfDestination && ( - <Badge variant="outline" className="text-xs">변경</Badge> - )} - </div> - </td> + <React.Fragment key={vendor.vendorId}> + <td className={cn( + "p-3 text-center border-r", + vendor.isSelected && "bg-blue-50" + )}> + {vendor.buyerConditions.placeOfDestination || "-"} + </td> + <td className={cn( + "p-3 text-center", + vendor.isSelected && "bg-blue-50" + )}> + <div className="flex items-center justify-center gap-2"> + {latestResponse?.vendorConditions?.placeOfDestination || vendor.buyerConditions.placeOfDestination || "-"} + {latestResponse?.vendorConditions?.placeOfDestination && + latestResponse.vendorConditions.placeOfDestination !== vendor.buyerConditions.placeOfDestination && ( + <Badge variant="outline" className="text-xs">변경</Badge> + )} + </div> + </td> + </React.Fragment> ); })} </tr> @@ -885,30 +930,35 @@ export function QuotationCompareView({ data }: QuotationCompareViewProps) { {/* 납기일 */} <tr> <td className="p-3 font-medium">납기일</td> - <td className="p-3"> - {data.vendors[0]?.buyerConditions.deliveryDate - ? format(new Date(data.vendors[0].buyerConditions.deliveryDate), "yyyy-MM-dd") - : "-"} - </td> {data.vendors.map((vendor) => { const latestResponse = vendor.responses[0]; return ( - <td key={vendor.vendorId} className={cn( - "p-3", - vendor.isSelected && "bg-blue-50" - )}> - <div className="flex items-center gap-2"> - {latestResponse?.vendorConditions?.deliveryDate - ? format(new Date(latestResponse.vendorConditions.deliveryDate), "yyyy-MM-dd") - : vendor.buyerConditions.deliveryDate - ? format(new Date(vendor.buyerConditions.deliveryDate), "yyyy-MM-dd") - : "-"} - {latestResponse?.vendorConditions?.deliveryDate && vendor.buyerConditions.deliveryDate && - new Date(latestResponse.vendorConditions.deliveryDate).getTime() !== new Date(vendor.buyerConditions.deliveryDate).getTime() && ( - <Badge variant="outline" className="text-xs">변경</Badge> - )} - </div> - </td> + <React.Fragment key={vendor.vendorId}> + <td className={cn( + "p-3 text-center border-r", + vendor.isSelected && "bg-blue-50" + )}> + {vendor.buyerConditions.deliveryDate + ? format(new Date(vendor.buyerConditions.deliveryDate), "yyyy-MM-dd") + : "-"} + </td> + <td className={cn( + "p-3 text-center", + vendor.isSelected && "bg-blue-50" + )}> + <div className="flex items-center justify-center gap-2"> + {latestResponse?.vendorConditions?.deliveryDate + ? format(new Date(latestResponse.vendorConditions.deliveryDate), "yyyy-MM-dd") + : vendor.buyerConditions.deliveryDate + ? format(new Date(vendor.buyerConditions.deliveryDate), "yyyy-MM-dd") + : "-"} + {/* {latestResponse?.vendorConditions?.deliveryDate && vendor.buyerConditions.deliveryDate && + new Date(latestResponse.vendorConditions.deliveryDate).getTime() !== new Date(vendor.buyerConditions.deliveryDate).getTime() && ( + <Badge variant="outline" className="text-xs">변경</Badge> + )} */} + </div> + </td> + </React.Fragment> ); })} </tr> @@ -916,22 +966,29 @@ export function QuotationCompareView({ data }: QuotationCompareViewProps) { {/* 세금조건 */} <tr> <td className="p-3 font-medium">세금조건</td> - <td className="p-3">{data.vendors[0]?.buyerConditions.taxCode || "-"}</td> {data.vendors.map((vendor) => { const latestResponse = vendor.responses[0]; return ( - <td key={vendor.vendorId} className={cn( - "p-3", - vendor.isSelected && "bg-blue-50" - )}> - <div className="flex items-center gap-2"> - {latestResponse?.vendorConditions?.taxCode || vendor.buyerConditions.taxCode || "-"} - {latestResponse?.vendorConditions?.taxCode && - latestResponse.vendorConditions.taxCode !== vendor.buyerConditions.taxCode && ( - <Badge variant="outline" className="text-xs">변경</Badge> - )} - </div> - </td> + <React.Fragment key={vendor.vendorId}> + <td className={cn( + "p-3 text-center border-r", + vendor.isSelected && "bg-blue-50" + )}> + {vendor.buyerConditions.taxCode || "-"} + </td> + <td className={cn( + "p-3 text-center", + vendor.isSelected && "bg-blue-50" + )}> + <div className="flex items-center justify-center gap-2"> + {latestResponse?.vendorConditions?.taxCode || vendor.buyerConditions.taxCode || "-"} + {latestResponse?.vendorConditions?.taxCode && + latestResponse.vendorConditions.taxCode !== vendor.buyerConditions.taxCode && ( + <Badge variant="outline" className="text-xs">변경</Badge> + )} + </div> + </td> + </React.Fragment> ); })} </tr> @@ -939,22 +996,29 @@ export function QuotationCompareView({ data }: QuotationCompareViewProps) { {/* 계약기간 */} <tr> <td className="p-3 font-medium">계약기간</td> - <td className="p-3">{data.vendors[0]?.buyerConditions.contractDuration || "-"}</td> {data.vendors.map((vendor) => { const latestResponse = vendor.responses[0]; return ( - <td key={vendor.vendorId} className={cn( - "p-3", - vendor.isSelected && "bg-blue-50" - )}> - <div className="flex items-center gap-2"> - {latestResponse?.vendorConditions?.contractDuration || vendor.buyerConditions.contractDuration || "-"} - {latestResponse?.vendorConditions?.contractDuration && - latestResponse.vendorConditions.contractDuration !== vendor.buyerConditions.contractDuration && ( - <Badge variant="outline" className="text-xs">변경</Badge> - )} - </div> - </td> + <React.Fragment key={vendor.vendorId}> + <td className={cn( + "p-3 text-center border-r", + vendor.isSelected && "bg-blue-50" + )}> + {vendor.buyerConditions.contractDuration || "-"} + </td> + <td className={cn( + "p-3 text-center", + vendor.isSelected && "bg-blue-50" + )}> + <div className="flex items-center justify-center gap-2"> + {latestResponse?.vendorConditions?.contractDuration || vendor.buyerConditions.contractDuration || "-"} + {latestResponse?.vendorConditions?.contractDuration && + latestResponse.vendorConditions.contractDuration !== vendor.buyerConditions.contractDuration && ( + <Badge variant="outline" className="text-xs">변경</Badge> + )} + </div> + </td> + </React.Fragment> ); })} </tr> @@ -980,11 +1044,13 @@ export function QuotationCompareView({ data }: QuotationCompareViewProps) { <tr className="border-b bg-gray-50"> <th className="text-left p-3 font-semibold">품목코드</th> <th className="text-left p-3 font-semibold">품목명</th> + <th className="text-left p-3 font-semibold">자재분류</th> <th className="text-right p-3 font-semibold">수량</th> + <th className="text-left p-3 font-semibold">규격</th> + <th className="text-right p-3 font-semibold">중량</th> <th className="text-right p-3 font-semibold">단가</th> <th className="text-right p-3 font-semibold">총액</th> - <th className="text-left p-3 font-semibold">납기</th> - <th className="text-left p-3 font-semibold">제조사</th> + <th className="text-left p-3 font-semibold">납기일자</th> </tr> </thead> <tbody className="divide-y"> @@ -994,15 +1060,30 @@ export function QuotationCompareView({ data }: QuotationCompareViewProps) { return ( <tr key={quoteItem.prItemId} className="hover:bg-gray-50"> - <td className="p-3 text-sm">{prItem.materialCode}</td> - <td className="p-3"> - <p className="font-medium">{prItem.materialDescription}</p> + <td className="p-3 text-sm"> + <p className="font-medium">{prItem.materialCode}</p> <p className="text-xs text-muted-foreground"> {prItem.prNo} • {prItem.prItem} </p> </td> + <td className="p-3"> + <p className="font-medium">{prItem.materialDescription}</p> + {prItem.remark && ( + <p className="text-xs text-muted-foreground mt-1">{prItem.remark}</p> + )} + </td> + <td className="p-3 text-sm"> + {prItem.materialCategory || "-"} + </td> <td className="p-3 text-right"> - {quoteItem.quantity} {prItem.uom} + <p>{quoteItem.quantity} {prItem.uom}</p> + <p className="text-xs text-muted-foreground">요청: {prItem.requestedQuantity}</p> + </td> + <td className="p-3 text-sm"> + {prItem.size || "-"} + </td> + <td className="p-3 text-right text-sm"> + {prItem.grossWeight ? `${prItem.grossWeight} ${prItem.gwUom || ""}` : "-"} </td> <td className="p-3 text-right font-medium"> {formatAmount(quoteItem.unitPrice, quoteItem.currency)} @@ -1016,16 +1097,11 @@ export function QuotationCompareView({ data }: QuotationCompareViewProps) { : quoteItem.leadTime ? `${quoteItem.leadTime}일` : "-"} - </td> - <td className="p-3 text-sm"> - {quoteItem.manufacturer ? ( - <div> - <p>{quoteItem.manufacturer}</p> - {quoteItem.modelNo && ( - <p className="text-xs text-muted-foreground">{quoteItem.modelNo}</p> - )} - </div> - ) : "-"} + {prItem.requestedDeliveryDate && ( + <p className="text-xs text-muted-foreground"> + 요청: {format(new Date(prItem.requestedDeliveryDate), "yyyy-MM-dd")} + </p> + )} </td> </tr> ); @@ -1054,6 +1130,76 @@ export function QuotationCompareView({ data }: QuotationCompareViewProps) { </div> </div> )} + + {/* 첨부파일 */} + {selectedResponse?.attachments && selectedResponse.attachments.length > 0 && ( + <div className="mt-6 pt-6 border-t"> + <h4 className="font-semibold mb-3 flex items-center gap-2"> + <Paperclip className="h-4 w-4" /> + 제출 문서 ({selectedResponse.attachments.length}건) + </h4> + <div className="space-y-2"> + {selectedResponse.attachments.map((attachment) => ( + <div + key={attachment.id} + className="flex items-center justify-between p-3 border rounded-lg hover:bg-gray-50" + > + <div className="flex items-center gap-3 flex-1"> + <FileText className="h-5 w-5 text-blue-500" /> + <div className="flex-1 min-w-0"> + <p className="font-medium text-sm truncate"> + {attachment.originalFileName} + </p> + <div className="flex items-center gap-3 text-xs text-muted-foreground mt-1"> + <span className="px-2 py-0.5 bg-blue-100 text-blue-700 rounded"> + {attachment.attachmentType} + </span> + {attachment.documentNo && ( + <span>문서번호: {attachment.documentNo}</span> + )} + {attachment.fileSize && ( + <span>{(attachment.fileSize / 1024).toFixed(1)} KB</span> + )} + {attachment.uploadedAt && ( + <span> + 업로드: {format(new Date(attachment.uploadedAt), "yyyy-MM-dd HH:mm")} + </span> + )} + </div> + {attachment.description && ( + <p className="text-xs text-muted-foreground mt-1"> + {attachment.description} + </p> + )} + {(attachment.validFrom || attachment.validTo) && ( + <p className="text-xs text-orange-600 mt-1"> + 유효기간: {attachment.validFrom ? format(new Date(attachment.validFrom), "yyyy-MM-dd") : "-"} ~ {attachment.validTo ? format(new Date(attachment.validTo), "yyyy-MM-dd") : "-"} + </p> + )} + </div> + </div> + <Button + size="sm" + variant="outline" + onClick={() => { + // 다운로드 처리 + const link = document.createElement('a'); + link.href = attachment.filePath; + link.download = attachment.originalFileName; + document.body.appendChild(link); + link.click(); + document.body.removeChild(link); + }} + className="gap-1" + > + <Download className="h-3 w-3" /> + 다운로드 + </Button> + </div> + ))} + </div> + </div> + )} </DialogContent> </Dialog> diff --git a/lib/rfq-last/vendor-response/editor/commercial-terms-form.tsx b/lib/rfq-last/vendor-response/editor/commercial-terms-form.tsx index d896ee34..b8b3a830 100644 --- a/lib/rfq-last/vendor-response/editor/commercial-terms-form.tsx +++ b/lib/rfq-last/vendor-response/editor/commercial-terms-form.tsx @@ -85,11 +85,14 @@ export default function CommercialTermsForm({ rfqDetail, rfq }: CommercialTermsF const isDifferentPaymentTerms = vendorPaymentTermsCode !== rfqDetail.paymentTermsCode const isDifferentIncoterms = vendorIncotermsCode !== rfqDetail.incotermsCode - // 날짜만 비교 (년월일만 체크) + // 날짜만 비교 (년월일만 체크) - 로컬 시간대 기준 const formatDateOnly = (date: Date | string | null) => { if (!date) return null const d = new Date(date) - return d.toISOString().split('T')[0] // YYYY-MM-DD 형식으로 변환 + const year = d.getFullYear() + const month = String(d.getMonth() + 1).padStart(2, '0') + const day = String(d.getDate()).padStart(2, '0') + return `${year}-${month}-${day}` } const isDifferentDeliveryDate = !isFrameContract && formatDateOnly(vendorDeliveryDate) !== formatDateOnly(rfqDetail.deliveryDate) diff --git a/lib/rfq-last/vendor/rfq-vendor-table.tsx b/lib/rfq-last/vendor/rfq-vendor-table.tsx index 428160d5..c0f80aca 100644 --- a/lib/rfq-last/vendor/rfq-vendor-table.tsx +++ b/lib/rfq-last/vendor/rfq-vendor-table.tsx @@ -344,10 +344,6 @@ export function RfqVendorTable({ row.response?.submission?.submittedAt ); - if (vendorsWithQuotation.length < 2) { - toast.warning("비교를 위해 최소 2개 이상의 견적서가 필요합니다."); - return; - } // 견적 비교 페이지로 이동 또는 모달 열기 const vendorIds = vendorsWithQuotation @@ -1656,7 +1652,7 @@ export function RfqVendorTable({ size="sm" onClick={handleQuotationCompare} disabled={quotationCount < 1} - className={quotationCount >= 2 ? "border-blue-500 text-blue-600 hover:bg-blue-50" : ""} + className={quotationCount >= 1 ? "border-blue-500 text-blue-600 hover:bg-blue-50" : ""} > <GitCompare className="h-4 w-4 mr-2" /> 견적 비교 |
