summaryrefslogtreecommitdiff
path: root/lib
diff options
context:
space:
mode:
Diffstat (limited to 'lib')
-rw-r--r--lib/rfq-last/compare-action.ts477
-rw-r--r--lib/rfq-last/contract-actions.ts5
-rw-r--r--lib/rfq-last/quotation-compare-view.tsx970
3 files changed, 791 insertions, 661 deletions
diff --git a/lib/rfq-last/compare-action.ts b/lib/rfq-last/compare-action.ts
index 2be594e9..1a50a373 100644
--- a/lib/rfq-last/compare-action.ts
+++ b/lib/rfq-last/compare-action.ts
@@ -1,7 +1,7 @@
"use server";
import db from "@/db/db";
-import { eq, and, inArray,ne } from "drizzle-orm";
+import { eq, and, inArray, ne, asc, desc } from "drizzle-orm";
import {
rfqsLast,
rfqLastDetails,
@@ -10,7 +10,9 @@ import {
rfqLastVendorQuotationItems,
vendors,
paymentTerms,
- incoterms,vendorSelections
+ incoterms,
+ vendorSelections,
+ users
} from "@/db/schema";
import { revalidatePath } from "next/cache";
import { getServerSession } from "next-auth/next"
@@ -44,14 +46,10 @@ export interface ComparisonData {
};
}
-export interface VendorComparison {
- vendorId: number;
- vendorName: string;
- vendorCode: string;
- vendorCountry?: string;
-
- // 응답 정보
+// 벤더의 각 차수별 응답 정보
+export interface VendorResponseVersion {
responseId: number;
+ responseVersion: number;
participationStatus: string;
responseStatus: string;
submittedAt: Date | null;
@@ -62,7 +60,58 @@ export interface VendorComparison {
rank?: number;
priceVariance?: number;
- // 구매자 제시 조건
+ // 벤더 제안 조건
+ vendorConditions: {
+ currency?: string;
+ paymentTermsCode?: string;
+ paymentTermsDesc?: string;
+ incotermsCode?: string;
+ incotermsDesc?: string;
+ deliveryDate?: Date | null;
+ contractDuration?: string;
+ taxCode?: string;
+ placeOfShipping?: string;
+ placeOfDestination?: string;
+ firstAcceptance?: "수용" | "부분수용" | "거부";
+ firstDescription?: string;
+ sparepartAcceptance?: "수용" | "부분수용" | "거부";
+ sparepartDescription?: string;
+ materialPriceRelatedYn?: boolean;
+ materialPriceRelatedReason?: string;
+ };
+
+ // 조건 차이 분석
+ conditionDifferences: {
+ hasDifferences: boolean;
+ differences: string[];
+ criticalDifferences: string[];
+ };
+
+ // 비고
+ generalRemark?: string;
+ technicalProposal?: string;
+
+ // 품목별 견적 아이템 정보 추가
+ quotationItems?: {
+ prItemId: number;
+ unitPrice: number;
+ totalPrice: number;
+ currency: string;
+ quantity: number;
+ deliveryDate?: Date | null;
+ leadTime?: number;
+ manufacturer?: string;
+ modelNo?: string;
+ }[];
+}
+
+export interface VendorComparison {
+ vendorId: number;
+ vendorName: string;
+ vendorCode: string;
+ vendorCountry?: string;
+
+ // 구매자 제시 조건 (모든 차수에 공통)
buyerConditions: {
currency: string;
paymentTermsCode: string;
@@ -81,7 +130,25 @@ export interface VendorComparison {
materialPriceRelatedYn: boolean;
};
- // 벤더 제안 조건
+ // 차수별 응답 배열 (최신 순)
+ responses: VendorResponseVersion[];
+
+ // 최신 응답 정보 (편의성)
+ latestResponse?: VendorResponseVersion;
+
+ // 응답 정보 (레거시, 하위 호환)
+ responseId: number;
+ participationStatus: string;
+ responseStatus: string;
+ submittedAt: Date | null;
+
+ // 가격 정보 (최신 응답 기준, 레거시 호환)
+ totalAmount: number;
+ currency: string;
+ rank?: number;
+ priceVariance?: number;
+
+ // 레거시 호환: 최신 응답의 조건 정보
vendorConditions: {
currency?: string;
paymentTermsCode?: string;
@@ -101,14 +168,14 @@ export interface VendorComparison {
materialPriceRelatedReason?: string;
};
- // 조건 차이 분석
+ // 레거시 호환: 최신 응답의 조건 차이 분석
conditionDifferences: {
hasDifferences: boolean;
differences: string[];
criticalDifferences: string[];
};
- // 비고
+ // 레거시 호환: 최신 응답의 비고
generalRemark?: string;
technicalProposal?: string;
@@ -188,7 +255,7 @@ export async function getComparisonData(
if (!rfqData[0]) return null;
- // 2. 벤더별 정보, 응답, 선정 정보 조회
+ // 2a. 벤더 기본 정보 + 구매자 조건 + 선정 정보 조회
const vendorData = await db
.select({
// 벤더 정보
@@ -228,14 +295,30 @@ export async function getComparisonData(
contractStatus: rfqLastDetails.contractStatus,
contractNo: rfqLastDetails.contractNo,
contractCreatedAt: rfqLastDetails.contractCreatedAt,
-
- // 벤더 응답
+ })
+ .from(vendors)
+ .innerJoin(
+ rfqLastDetails,
+ and(
+ eq(rfqLastDetails.vendorsId, vendors.id),
+ eq(rfqLastDetails.rfqsLastId, rfqId),
+ eq(rfqLastDetails.isLatest, true)
+ )
+ )
+ .where(inArray(vendors.id, vendorIds));
+
+ // 2b. 모든 차수의 벤더 응답 조회 (isLatest 조건 제거)
+ const allVendorResponses = await db
+ .select({
responseId: rfqLastVendorResponses.id,
+ vendorId: rfqLastVendorResponses.vendorId,
+ responseVersion: rfqLastVendorResponses.responseVersion,
+ isLatest: rfqLastVendorResponses.isLatest,
participationStatus: rfqLastVendorResponses.participationStatus,
responseStatus: rfqLastVendorResponses.status,
submittedAt: rfqLastVendorResponses.submittedAt,
totalAmount: rfqLastVendorResponses.totalAmount,
- responseCurrency: rfqLastVendorResponses.currency,
+ currency: rfqLastVendorResponses.currency,
// 벤더 제안 조건
vendorCurrency: rfqLastVendorResponses.vendorCurrency,
@@ -260,24 +343,14 @@ export async function getComparisonData(
generalRemark: rfqLastVendorResponses.generalRemark,
technicalProposal: rfqLastVendorResponses.technicalProposal,
})
- .from(vendors)
- .innerJoin(
- rfqLastDetails,
- and(
- eq(rfqLastDetails.vendorsId, vendors.id),
- eq(rfqLastDetails.rfqsLastId, rfqId),
- eq(rfqLastDetails.isLatest, true)
- )
- )
- .leftJoin(
- rfqLastVendorResponses,
+ .from(rfqLastVendorResponses)
+ .where(
and(
- eq(rfqLastVendorResponses.vendorId, vendors.id),
eq(rfqLastVendorResponses.rfqsLastId, rfqId),
- eq(rfqLastVendorResponses.isLatest, true)
+ inArray(rfqLastVendorResponses.vendorId, vendorIds)
)
)
- .where(inArray(vendors.id, vendorIds));
+ .orderBy(asc(rfqLastVendorResponses.vendorId), asc(rfqLastVendorResponses.responseVersion));
// 3. 선정자 이름 조회 (선정된 업체가 있는 경우)
const selectedVendor = vendorData.find(v => v.isSelected);
@@ -328,90 +401,165 @@ export async function getComparisonData(
.from(rfqPrItems)
.where(eq(rfqPrItems.rfqsLastId, rfqId));
- // 6. 벤더별 견적 아이템 조회
- const quotationItems = await db
- .select({
- vendorResponseId: rfqLastVendorQuotationItems.vendorResponseId,
- prItemId: rfqLastVendorQuotationItems.rfqPrItemId,
- unitPrice: rfqLastVendorQuotationItems.unitPrice,
- totalPrice: rfqLastVendorQuotationItems.totalPrice,
- currency: rfqLastVendorQuotationItems.currency,
- quantity: rfqLastVendorQuotationItems.quantity,
- deliveryDate: rfqLastVendorQuotationItems.vendorDeliveryDate,
- leadTime: rfqLastVendorQuotationItems.leadTime,
- manufacturer: rfqLastVendorQuotationItems.manufacturer,
- modelNo: rfqLastVendorQuotationItems.modelNo,
- technicalCompliance: rfqLastVendorQuotationItems.technicalCompliance,
- alternativeProposal: rfqLastVendorQuotationItems.alternativeProposal,
- itemRemark: rfqLastVendorQuotationItems.itemRemark,
- })
- .from(rfqLastVendorQuotationItems)
- .where(
- inArray(
- rfqLastVendorQuotationItems.vendorResponseId,
- vendorData.map(v => v.responseId).filter(id => id != null)
- )
- );
-
- // 7. 데이터 가공 및 분석
- const validAmounts = vendorData
- .map(v => v.totalAmount)
- .filter(a => a != null && a > 0);
-
- const minAmount = Math.min(...validAmounts);
- const maxAmount = Math.max(...validAmounts);
- const avgAmount = validAmounts.reduce((a, b) => a + b, 0) / validAmounts.length;
-
- // 8. 벤더별 비교 데이터 구성
- const vendorComparisons: VendorComparison[] = vendorData.map((v, index) => {
- const differences: string[] = [];
- const criticalDifferences: string[] = [];
-
- // 조건 차이 분석
- if (v.vendorCurrency && v.vendorCurrency !== v.buyerCurrency) {
- criticalDifferences.push(`통화: ${v.buyerCurrency} → ${v.vendorCurrency}`);
- }
-
- if (v.vendorPaymentTermsCode && v.vendorPaymentTermsCode !== v.buyerPaymentTermsCode) {
- differences.push(`지급조건: ${v.buyerPaymentTermsCode} → ${v.vendorPaymentTermsCode}`);
- }
-
- if (v.vendorIncotermsCode && v.vendorIncotermsCode !== v.buyerIncotermsCode) {
- differences.push(`인코텀즈: ${v.buyerIncotermsCode} → ${v.vendorIncotermsCode}`);
+ // 6. 벤더별 견적 아이템 조회 (모든 응답 버전 포함)
+ const allResponseIds = allVendorResponses.map(r => r.responseId);
+ const quotationItems = allResponseIds.length > 0
+ ? await db
+ .select({
+ vendorResponseId: rfqLastVendorQuotationItems.vendorResponseId,
+ prItemId: rfqLastVendorQuotationItems.rfqPrItemId,
+ unitPrice: rfqLastVendorQuotationItems.unitPrice,
+ totalPrice: rfqLastVendorQuotationItems.totalPrice,
+ currency: rfqLastVendorQuotationItems.currency,
+ quantity: rfqLastVendorQuotationItems.quantity,
+ deliveryDate: rfqLastVendorQuotationItems.vendorDeliveryDate,
+ leadTime: rfqLastVendorQuotationItems.leadTime,
+ manufacturer: rfqLastVendorQuotationItems.manufacturer,
+ modelNo: rfqLastVendorQuotationItems.modelNo,
+ technicalCompliance: rfqLastVendorQuotationItems.technicalCompliance,
+ alternativeProposal: rfqLastVendorQuotationItems.alternativeProposal,
+ itemRemark: rfqLastVendorQuotationItems.itemRemark,
+ })
+ .from(rfqLastVendorQuotationItems)
+ .where(inArray(rfqLastVendorQuotationItems.vendorResponseId, allResponseIds))
+ : [];
+
+ // 7. 데이터 가공 및 분석 - 각 벤더별 최신 차수 기준으로 평균 계산
+ // 각 벤더별로 가장 높은 responseVersion을 가진 응답 찾기
+ const latestResponsesByVendor = new Map<number, typeof allVendorResponses[0]>();
+ allVendorResponses.forEach(response => {
+ const existing = latestResponsesByVendor.get(response.vendorId);
+ if (!existing || response.responseVersion > existing.responseVersion) {
+ latestResponsesByVendor.set(response.vendorId, response);
}
+ });
+
+ const latestResponses = Array.from(latestResponsesByVendor.values());
+ const validAmounts = latestResponses
+ .map(r => r.totalAmount)
+ .filter((a): a is number => a != null && a > 0);
+
+ console.log("Latest responses:", latestResponses.map(r => ({
+ vendorId: r.vendorId,
+ version: r.responseVersion,
+ amount: r.totalAmount,
+ isLatest: r.isLatest
+ })));
+ console.log("Valid amounts for average:", validAmounts);
+
+ const minAmount = validAmounts.length > 0 ? Math.min(...validAmounts) : 0;
+ const maxAmount = validAmounts.length > 0 ? Math.max(...validAmounts) : 0;
+ const avgAmount = validAmounts.length > 0
+ ? validAmounts.reduce((a, b) => a + b, 0) / validAmounts.length
+ : 0;
+
+ // 8. 벤더별 비교 데이터 구성 (차수별 응답 포함)
+ const vendorComparisons: VendorComparison[] = vendorData.map((v) => {
+ // 이 벤더의 모든 응답 가져오기
+ const vendorResponses = allVendorResponses.filter(r => r.vendorId === v.vendorId);
- if (v.vendorDeliveryDate && v.buyerDeliveryDate) {
- const buyerDate = new Date(v.buyerDeliveryDate);
- const vendorDate = new Date(v.vendorDeliveryDate);
- if (vendorDate > buyerDate) {
- criticalDifferences.push(`납기: ${Math.ceil((vendorDate.getTime() - buyerDate.getTime()) / (1000 * 60 * 60 * 24))}일 지연`);
+ // 차수별 응답 정보 구성
+ const responses: VendorResponseVersion[] = vendorResponses.map(resp => {
+ const differences: string[] = [];
+ const criticalDifferences: string[] = [];
+
+ // 조건 차이 분석
+ if (resp.vendorCurrency && resp.vendorCurrency !== v.buyerCurrency) {
+ criticalDifferences.push(`통화: ${v.buyerCurrency} → ${resp.vendorCurrency}`);
+ }
+
+ if (resp.vendorPaymentTermsCode && resp.vendorPaymentTermsCode !== v.buyerPaymentTermsCode) {
+ differences.push(`지급조건: ${v.buyerPaymentTermsCode} → ${resp.vendorPaymentTermsCode}`);
+ }
+
+ if (resp.vendorIncotermsCode && resp.vendorIncotermsCode !== v.buyerIncotermsCode) {
+ differences.push(`인코텀즈: ${v.buyerIncotermsCode} → ${resp.vendorIncotermsCode}`);
+ }
+
+ if (resp.vendorDeliveryDate && v.buyerDeliveryDate) {
+ const buyerDate = new Date(v.buyerDeliveryDate);
+ const vendorDate = new Date(resp.vendorDeliveryDate);
+ if (vendorDate > buyerDate) {
+ criticalDifferences.push(`납기: ${Math.ceil((vendorDate.getTime() - buyerDate.getTime()) / (1000 * 60 * 60 * 24))}일 지연`);
+ }
}
- }
- if (v.vendorFirstAcceptance === "거부" && v.buyerFirstYn) {
- criticalDifferences.push("초도품 거부");
- }
-
- if (v.vendorSparepartAcceptance === "거부" && v.buyerSparepartYn) {
- criticalDifferences.push("스페어파트 거부");
- }
+ if (resp.vendorFirstAcceptance === "거부" && v.buyerFirstYn) {
+ criticalDifferences.push("초도품 거부");
+ }
+
+ if (resp.vendorSparepartAcceptance === "거부" && v.buyerSparepartYn) {
+ criticalDifferences.push("스페어파트 거부");
+ }
+ // 이 응답의 품목별 견적 아이템 가져오기
+ const responseQuotationItems = quotationItems
+ .filter(q => q.vendorResponseId === resp.responseId)
+ .map(q => ({
+ prItemId: q.prItemId,
+ unitPrice: q.unitPrice || 0,
+ totalPrice: q.totalPrice || 0,
+ currency: q.currency || "USD",
+ quantity: q.quantity || 0,
+ deliveryDate: q.deliveryDate,
+ leadTime: q.leadTime,
+ manufacturer: q.manufacturer,
+ modelNo: q.modelNo,
+ }));
+
+ return {
+ responseId: resp.responseId,
+ responseVersion: resp.responseVersion,
+ participationStatus: resp.participationStatus || "미응답",
+ responseStatus: resp.responseStatus || "대기중",
+ submittedAt: resp.submittedAt,
+
+ totalAmount: resp.totalAmount || 0,
+ currency: resp.currency || v.buyerCurrency || "USD",
+ rank: 0, // 나중에 계산
+ priceVariance: resp.totalAmount ? ((resp.totalAmount - avgAmount) / avgAmount) * 100 : 0,
+
+ vendorConditions: {
+ currency: resp.vendorCurrency || undefined,
+ paymentTermsCode: resp.vendorPaymentTermsCode || undefined,
+ paymentTermsDesc: paymentTermsMap.get(resp.vendorPaymentTermsCode || ""),
+ incotermsCode: resp.vendorIncotermsCode || undefined,
+ incotermsDesc: incotermsMap.get(resp.vendorIncotermsCode || ""),
+ deliveryDate: resp.vendorDeliveryDate,
+ contractDuration: resp.vendorContractDuration || undefined,
+ taxCode: resp.vendorTaxCode || undefined,
+ placeOfShipping: resp.vendorPlaceOfShipping || undefined,
+ placeOfDestination: resp.vendorPlaceOfDestination || undefined,
+ firstAcceptance: resp.vendorFirstAcceptance || undefined,
+ firstDescription: resp.vendorFirstDescription || undefined,
+ sparepartAcceptance: resp.vendorSparepartAcceptance || undefined,
+ sparepartDescription: resp.vendorSparepartDescription || undefined,
+ materialPriceRelatedYn: resp.vendorMaterialPriceRelatedYn || undefined,
+ materialPriceRelatedReason: resp.vendorMaterialPriceRelatedReason || undefined,
+ },
+
+ conditionDifferences: {
+ hasDifferences: differences.length > 0 || criticalDifferences.length > 0,
+ differences,
+ criticalDifferences,
+ },
+
+ generalRemark: resp.generalRemark,
+ technicalProposal: resp.technicalProposal,
+ quotationItems: responseQuotationItems,
+ };
+ });
+
+ // 최신 응답 찾기
+ const latestResp = responses.find(r => r.responseVersion === Math.max(...responses.map(r => r.responseVersion)));
+
return {
vendorId: v.vendorId,
vendorName: v.vendorName,
- vendorCode: v.vendorCode,
- vendorCountry: v.vendorCountry,
-
- responseId: v.responseId || 0,
- participationStatus: v.participationStatus || "미응답",
- responseStatus: v.responseStatus || "대기중",
- submittedAt: v.submittedAt,
-
- totalAmount: v.totalAmount || 0,
- currency: v.responseCurrency || v.buyerCurrency || "USD",
- rank: 0, // 나중에 계산
- priceVariance: v.totalAmount ? ((v.totalAmount - avgAmount) / avgAmount) * 100 : 0,
+ vendorCode: v.vendorCode || "",
+ vendorCountry: v.vendorCountry || undefined,
+ // 구매자 제시 조건
buyerConditions: {
currency: v.buyerCurrency || "USD",
paymentTermsCode: v.buyerPaymentTermsCode || "",
@@ -430,33 +578,48 @@ export async function getComparisonData(
materialPriceRelatedYn: v.buyerMaterialPriceRelatedYn || false,
},
- vendorConditions: {
- currency: v.vendorCurrency,
- paymentTermsCode: v.vendorPaymentTermsCode,
- paymentTermsDesc: paymentTermsMap.get(v.vendorPaymentTermsCode || ""),
- incotermsCode: v.vendorIncotermsCode,
- incotermsDesc: incotermsMap.get(v.vendorIncotermsCode || ""),
- deliveryDate: v.vendorDeliveryDate,
- contractDuration: v.vendorContractDuration,
- taxCode: v.vendorTaxCode,
- placeOfShipping: v.vendorPlaceOfShipping,
- placeOfDestination: v.vendorPlaceOfDestination,
- firstAcceptance: v.vendorFirstAcceptance,
- firstDescription: v.vendorFirstDescription,
- sparepartAcceptance: v.vendorSparepartAcceptance,
- sparepartDescription: v.vendorSparepartDescription,
- materialPriceRelatedYn: v.vendorMaterialPriceRelatedYn,
- materialPriceRelatedReason: v.vendorMaterialPriceRelatedReason,
+ // 차수별 응답 배열
+ responses,
+ latestResponse: latestResp,
+
+ // 레거시 호환 필드 (최신 응답 기준)
+ responseId: latestResp?.responseId || 0,
+ participationStatus: latestResp?.participationStatus || "미응답",
+ responseStatus: latestResp?.responseStatus || "대기중",
+ submittedAt: latestResp?.submittedAt || null,
+
+ totalAmount: latestResp?.totalAmount || 0,
+ currency: latestResp?.currency || v.buyerCurrency || "USD",
+ rank: 0, // 나중에 계산
+ priceVariance: latestResp?.priceVariance || 0,
+
+ vendorConditions: latestResp?.vendorConditions || {
+ currency: undefined,
+ paymentTermsCode: undefined,
+ paymentTermsDesc: undefined,
+ incotermsCode: undefined,
+ incotermsDesc: undefined,
+ deliveryDate: null,
+ contractDuration: undefined,
+ taxCode: undefined,
+ placeOfShipping: undefined,
+ placeOfDestination: undefined,
+ firstAcceptance: undefined,
+ firstDescription: undefined,
+ sparepartAcceptance: undefined,
+ sparepartDescription: undefined,
+ materialPriceRelatedYn: undefined,
+ materialPriceRelatedReason: undefined,
},
- conditionDifferences: {
- hasDifferences: differences.length > 0 || criticalDifferences.length > 0,
- differences,
- criticalDifferences,
+ conditionDifferences: latestResp?.conditionDifferences || {
+ hasDifferences: false,
+ differences: [],
+ criticalDifferences: [],
},
- generalRemark: v.generalRemark,
- technicalProposal: v.technicalProposal,
+ generalRemark: latestResp?.generalRemark,
+ technicalProposal: latestResp?.technicalProposal,
// 선정 관련 정보
isSelected: v.isSelected || false,
@@ -487,7 +650,10 @@ export async function getComparisonData(
const itemQuotes = quotationItems
.filter(q => q.prItemId === item.id)
.map(q => {
- const vendor = vendorData.find(v => v.responseId === q.vendorResponseId);
+ // vendorResponseId로 응답 찾기
+ const response = allVendorResponses.find(r => r.responseId === q.vendorResponseId);
+ // 그 응답의 vendorId로 벤더 정보 찾기
+ const vendor = vendorData.find(v => v.vendorId === response?.vendorId);
return {
vendorId: vendor?.vendorId || 0,
vendorName: vendor?.vendorName || "",
@@ -495,13 +661,13 @@ export async function getComparisonData(
totalPrice: q.totalPrice || 0,
currency: q.currency || "USD",
quotedQuantity: 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,
technicalCompliance: q.technicalCompliance || true,
- alternativeProposal: q.alternativeProposal,
- itemRemark: q.itemRemark,
+ alternativeProposal: q.alternativeProposal || undefined,
+ itemRemark: q.itemRemark || undefined,
priceRank: 0,
};
});
@@ -555,7 +721,14 @@ export async function getComparisonData(
// 11. 최종 데이터 반환
return {
- rfqInfo: rfqData[0],
+ rfqInfo: {
+ ...rfqData[0],
+ rfqCode: rfqData[0].rfqCode || "",
+ rfqTitle: rfqData[0].rfqTitle || "",
+ rfqType: rfqData[0].rfqType || "",
+ packageNo: rfqData[0].packageNo || undefined,
+ packageName: rfqData[0].packageName || undefined,
+ },
vendors: vendorComparisons,
prItems: prItemComparisons,
summary: {
@@ -586,11 +759,17 @@ interface SelectVendorParams {
priceRank: number;
hasConditionDifferences: boolean;
criticalDifferences: string[];
- userId: number; // 현재 사용자 ID
}
export async function selectVendor(params: SelectVendorParams) {
try {
+ // 세션에서 사용자 ID 가져오기
+ const session = await getServerSession(authOptions);
+ if (!session?.user) {
+ throw new Error("인증이 필요합니다.");
+ }
+ const userId = Number(session.user.id);
+
// 트랜잭션 시작
const result = await db.transaction(async (tx) => {
// 1. RFQ 상태 확인
@@ -613,7 +792,7 @@ export async function selectVendor(params: SelectVendorParams) {
.set({
isSelected: false,
updatedAt: new Date(),
- updatedBy: params.userId
+ updatedBy: userId
})
.where(
and(
@@ -629,11 +808,11 @@ export async function selectVendor(params: SelectVendorParams) {
isSelected: true,
selectionDate: new Date(),
selectionReason: params.selectionReason,
- selectedBy: params.userId,
+ selectedBy: userId,
totalAmount: params.totalAmount.toString(),
priceRank: params.priceRank,
updatedAt: new Date(),
- updatedBy: params.userId,
+ updatedBy: userId,
})
.where(
and(
@@ -868,16 +1047,8 @@ export async function processSelectionApproval(
throw new Error("선정된 업체를 찾을 수 없습니다.");
}
- // 승인된 경우 RFQ 상태 업데이트
- if (action === "승인") {
- await tx
- .update(rfqsLast)
- .set({
- status: "계약 진행중",
- updatedAt: new Date(),
- })
- .where(eq(rfqsLast.id, rfqId));
- }
+ // 승인된 경우 RFQ 상태는 이미 "최종업체선정"이므로 별도 업데이트 불필요
+ // (선정 시 이미 "최종업체선정"으로 설정됨)
});
revalidatePath(`/evcp/rfq-last/${rfqId}`);
@@ -950,8 +1121,8 @@ export async function getRfqQuotationStatus(rfqId: number) {
.select({
id: rfqLastDetails.id,
vendorId: rfqLastDetails.vendorsId,
- vendorName: vendors.name,
- vendorCode: vendors.code,
+ vendorName: vendors.vendorName,
+ vendorCode: vendors.vendorCode,
totalAmount: rfqLastDetails.totalAmount,
currency: rfqLastDetails.currency,
priceRank: rfqLastDetails.priceRank,
diff --git a/lib/rfq-last/contract-actions.ts b/lib/rfq-last/contract-actions.ts
index 082716a0..1f86352a 100644
--- a/lib/rfq-last/contract-actions.ts
+++ b/lib/rfq-last/contract-actions.ts
@@ -146,9 +146,10 @@ export async function createGeneralContract(params: CreateGeneralContractParams)
.where(eq(rfqPrItems.rfqsLastId, params.rfqId));
// 3. 계약번호 생성 - generateContractNumber 함수 사용
+ // 매개변수 순서: (userId, contractType)
const contractNumber = await generateContractNumber(
- params.contractType, // 계약종류 (UP, LE, IL 등)
- rfqData.rfq.picCode || undefined // 발주담당자 코드
+ rfqData.rfq.picCode || undefined, // 발주담당자 코드 (userId)
+ params.contractType // 계약종류 (UP, LE, IL 등)
);
// 4. 트랜잭션으로 계약 생성
diff --git a/lib/rfq-last/quotation-compare-view.tsx b/lib/rfq-last/quotation-compare-view.tsx
index 91d46295..723d1044 100644
--- a/lib/rfq-last/quotation-compare-view.tsx
+++ b/lib/rfq-last/quotation-compare-view.tsx
@@ -43,7 +43,13 @@ import {
CollapsibleContent,
CollapsibleTrigger,
} from "@/components/ui/collapsible";
-import { ComparisonData, selectVendor, cancelVendorSelection } from "./compare-action";
+import {
+ Dialog,
+ DialogContent,
+ DialogHeader,
+ DialogTitle,
+} from "@/components/ui/dialog";
+import { ComparisonData, selectVendor, cancelVendorSelection, VendorResponseVersion } from "./compare-action";
import { createPO, createGeneralContract, createBidding } from "./contract-actions";
import { toast } from "sonner";
import { useRouter } from "next/navigation"
@@ -58,6 +64,9 @@ export function QuotationCompareView({ data }: QuotationCompareViewProps) {
const [showSelectionDialog, setShowSelectionDialog] = React.useState(false);
const [showCancelDialog, setShowCancelDialog] = React.useState(false);
const [showContractDialog, setShowContractDialog] = React.useState(false);
+ const [showItemDetailsDialog, setShowItemDetailsDialog] = React.useState(false);
+ const [selectedResponse, setSelectedResponse] = React.useState<VendorResponseVersion | null>(null);
+ const [selectedVendorName, setSelectedVendorName] = React.useState<string>("");
const [selectedContractType, setSelectedContractType] = React.useState<"PO" | "CONTRACT" | "BIDDING" | "">("");
const [selectionReason, setSelectionReason] = React.useState("");
const [cancelReason, setCancelReason] = React.useState("");
@@ -88,7 +97,7 @@ export function QuotationCompareView({ data }: QuotationCompareViewProps) {
];
// 입찰 관련 state 추가
- const [biddingContractType, setBiddingContractType] = React.useState<"unit_price" | "general" | "sale" | "">("");
+ const [biddingContractType, setBiddingContractType] = React.useState<"unit_price" | "general" | "sale">("unit_price");
const [biddingType, setBiddingType] = React.useState<string>("");
const [awardCount, setAwardCount] = React.useState<"single" | "multiple">("single");
const [biddingStartDate, setBiddingStartDate] = React.useState("");
@@ -236,7 +245,7 @@ export function QuotationCompareView({ data }: QuotationCompareViewProps) {
setSelectedGeneralContractType("");
setContractStartDate("");
setContractEndDate("");
- setBiddingContractType("");
+ setBiddingContractType("unit_price");
setBiddingType("");
setAwardCount("single");
setBiddingStartDate("");
@@ -323,17 +332,23 @@ export function QuotationCompareView({ data }: QuotationCompareViewProps) {
throw new Error("선택한 업체를 찾을 수 없습니다.");
}
+ // 최신 응답 정보 사용
+ const latestResponse = vendor.responses[0];
+ if (!latestResponse) {
+ throw new Error("업체의 견적 정보를 찾을 수 없습니다.");
+ }
+
const result = await selectVendor({
rfqId: data.rfqInfo.id,
vendorId: vendor.vendorId,
vendorName: vendor.vendorName,
vendorCode: vendor.vendorCode,
- totalAmount: vendor.totalAmount,
- currency: vendor.currency,
+ totalAmount: latestResponse.totalAmount,
+ currency: latestResponse.currency,
selectionReason: selectionReason,
- priceRank: vendor.rank || 0,
- hasConditionDifferences: vendor.conditionDifferences.hasDifferences,
- criticalDifferences: vendor.conditionDifferences.criticalDifferences,
+ priceRank: latestResponse.rank || 0,
+ hasConditionDifferences: latestResponse.conditionDifferences.hasDifferences,
+ criticalDifferences: latestResponse.conditionDifferences.criticalDifferences,
});
if (result.success) {
@@ -526,7 +541,7 @@ export function QuotationCompareView({ data }: QuotationCompareViewProps) {
)}
{/* 요약 카드 */}
- <div className="grid grid-cols-1 md:grid-cols-4 gap-4">
+ <div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{/* 최저가 벤더 */}
<Card>
<CardHeader className="pb-3">
@@ -577,147 +592,111 @@ export function QuotationCompareView({ data }: QuotationCompareViewProps) {
</CardContent>
</Card>
)}
-
- {/* 가격 범위 */}
- <Card>
- <CardHeader className="pb-3">
- <CardTitle className="text-sm font-medium flex items-center gap-2">
- <TrendingUp className="h-4 w-4" />
- 가격 범위
- </CardTitle>
- </CardHeader>
- <CardContent>
- <p className="text-lg font-bold">
- {((data.summary.priceRange.max - data.summary.priceRange.min) / data.summary.priceRange.min * 100).toFixed(1)}%
- </p>
- <p className="text-sm text-muted-foreground">
- 최저가 대비 최고가 차이
- </p>
- </CardContent>
- </Card>
-
- {/* 조건 불일치 */}
- <Card>
- <CardHeader className="pb-3">
- <CardTitle className="text-sm font-medium flex items-center gap-2">
- <AlertCircle className="h-4 w-4 text-orange-500" />
- 조건 불일치
- </CardTitle>
- </CardHeader>
- <CardContent>
- <p className="text-lg font-bold">
- {data.vendors.filter(v => v.conditionDifferences.hasDifferences).length}개
- </p>
- <p className="text-sm text-muted-foreground">
- 제시 조건과 차이 있음
- </p>
- </CardContent>
- </Card>
</div>
{/* 탭 뷰 */}
<Tabs defaultValue="overview" className="w-full">
- <TabsList className="grid w-full grid-cols-4">
+ <TabsList className="grid w-full grid-cols-2">
<TabsTrigger value="overview">종합 비교</TabsTrigger>
<TabsTrigger value="conditions">조건 비교</TabsTrigger>
- <TabsTrigger value="items">아이템별 비교</TabsTrigger>
- <TabsTrigger value="analysis">상세 분석</TabsTrigger>
</TabsList>
{/* 종합 비교 */}
<TabsContent value="overview" className="space-y-4">
<Card>
<CardHeader>
- <CardTitle>가격 순위 및 업체 선정</CardTitle>
+ <CardTitle>협력업체별 차수별 견적</CardTitle>
</CardHeader>
<CardContent>
- <div className="space-y-4">
- {data.vendors.map((vendor) => (
- <div
- key={vendor.vendorId}
- className={cn(
- "flex items-center justify-between p-4 border rounded-lg transition-colors",
- vendor.isSelected
- ? "bg-blue-100 border-blue-400 border-2"
- : hasSelection
- ? "opacity-60"
- : selectedVendorId === vendor.vendorId.toString()
- ? "bg-blue-50 border-blue-300 cursor-pointer"
- : "hover:bg-gray-50 cursor-pointer"
- )}
- onClick={() => {
- if (!hasSelection) {
- setSelectedVendorId(vendor.vendorId.toString());
- }
- }}
- >
- <div className="flex items-center gap-4">
- {!hasSelection && (
- <input
- type="radio"
- name="vendor-selection"
- value={vendor.vendorId}
- checked={selectedVendorId === vendor.vendorId.toString()}
- onChange={(e) => setSelectedVendorId(e.target.value)}
- className="h-4 w-4 text-blue-600"
- />
- )}
- <div
+ <div className="overflow-x-auto">
+ <table className="w-full">
+ <thead>
+ <tr className="border-b bg-gray-50">
+ {!hasSelection && <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>
+ {(() => {
+ // 모든 차수 추출 (중복 제거 및 내림차순 정렬)
+ const allVersions = Array.from(
+ new Set(
+ data.vendors.flatMap(v => v.responses.map(r => r.responseVersion))
+ )
+ ).sort((a, b) => b - a);
+
+ return allVersions.map(version => (
+ <th key={version} className="text-right p-3 font-semibold">
+ {version}차
+ </th>
+ ));
+ })()}
+ </tr>
+ </thead>
+ <tbody className="divide-y">
+ {data.vendors.map((vendor) => (
+ <tr
+ key={vendor.vendorId}
className={cn(
- "w-10 h-10 rounded-full flex items-center justify-center font-bold",
- getRankColor(vendor.rank || 0)
+ "hover:bg-gray-50 transition-colors",
+ vendor.isSelected && "bg-blue-50"
)}
>
- {vendor.rank}
- </div>
- <div>
- <p className="font-semibold flex items-center gap-2">
- {vendor.vendorName}
- {vendor.isSelected && (
- <Badge className="bg-blue-600">선정</Badge>
- )}
- </p>
- <p className="text-sm text-muted-foreground">
- {vendor.vendorCode} • {vendor.vendorCountry}
- </p>
- </div>
- </div>
-
- <div className="flex items-center gap-6">
- {/* 조건 차이 표시 */}
- {vendor.conditionDifferences.criticalDifferences.length > 0 && (
- <TooltipProvider>
- <Tooltip>
- <TooltipTrigger>
- <Badge variant="destructive" className="gap-1">
- <AlertTriangle className="h-3 w-3" />
- 중요 차이 {vendor.conditionDifferences.criticalDifferences.length}
- </Badge>
- </TooltipTrigger>
- <TooltipContent>
- <div className="space-y-1">
- {vendor.conditionDifferences.criticalDifferences.map((diff, idx) => (
- <p key={idx} className="text-xs">{diff}</p>
- ))}
- </div>
- </TooltipContent>
- </Tooltip>
- </TooltipProvider>
- )}
-
- {/* 가격 정보 */}
- <div className="text-right">
- <p className="text-lg font-bold">
- {formatAmount(vendor.totalAmount, vendor.currency)}
- </p>
- <p className={cn("text-sm", getVarianceColor(vendor.priceVariance || 0))}>
- {vendor.priceVariance && vendor.priceVariance > 0 ? "+" : ""}
- {vendor.priceVariance?.toFixed(1)}% vs 평균
- </p>
- </div>
- </div>
- </div>
- ))}
+ {!hasSelection && (
+ <td className="p-3">
+ <input
+ type="radio"
+ name="vendor-selection"
+ value={vendor.vendorId}
+ checked={selectedVendorId === vendor.vendorId.toString()}
+ onChange={(e) => setSelectedVendorId(e.target.value)}
+ className="h-4 w-4 text-blue-600"
+ />
+ </td>
+ )}
+ <td className="p-3 text-sm font-medium">{vendor.vendorCode}</td>
+ <td className="p-3">
+ <div className="flex items-center gap-2">
+ <span className="font-semibold">{vendor.vendorName}</span>
+ {vendor.isSelected && (
+ <Badge className="bg-blue-600 text-xs">선정</Badge>
+ )}
+ </div>
+ </td>
+ {(() => {
+ const allVersions = Array.from(
+ new Set(
+ data.vendors.flatMap(v => v.responses.map(r => r.responseVersion))
+ )
+ ).sort((a, b) => b - a);
+
+ return allVersions.map(version => {
+ const response = vendor.responses.find(r => r.responseVersion === version);
+
+ return (
+ <td key={version} className="p-3 text-right">
+ {response ? (
+ <Button
+ variant="link"
+ size="sm"
+ className="h-auto p-0 font-bold text-blue-600 hover:text-blue-800"
+ onClick={() => {
+ setSelectedResponse(response);
+ setSelectedVendorName(vendor.vendorName);
+ setShowItemDetailsDialog(true);
+ }}
+ >
+ {formatAmount(response.totalAmount, response.currency)}
+ </Button>
+ ) : (
+ <span className="text-sm text-gray-400">-</span>
+ )}
+ </td>
+ );
+ });
+ })()}
+ </tr>
+ ))}
+ </tbody>
+ </table>
</div>
</CardContent>
</Card>
@@ -733,16 +712,16 @@ export function QuotationCompareView({ data }: QuotationCompareViewProps) {
<table className="w-full">
<thead>
<tr className="border-b">
- <th className="text-left p-2">항목</th>
- <th className="text-left p-2">구매자 제시</th>
+ <th className="text-left p-3 font-semibold">항목</th>
+ <th className="text-left p-3 font-semibold">구매자 제시</th>
{data.vendors.map((vendor) => (
<th key={vendor.vendorId} className={cn(
- "text-left p-2",
+ "text-left p-3 font-semibold",
vendor.isSelected && "bg-blue-50"
)}>
{vendor.vendorName}
{vendor.isSelected && (
- <Badge className="ml-2 bg-blue-600">선정</Badge>
+ <Badge className="ml-2 bg-blue-600 text-xs">선정</Badge>
)}
</th>
))}
@@ -751,30 +730,33 @@ export function QuotationCompareView({ data }: QuotationCompareViewProps) {
<tbody className="divide-y">
{/* 통화 */}
<tr>
- <td className="p-2 font-medium">통화</td>
- <td className="p-2">{data.vendors[0]?.buyerConditions.currency}</td>
- {data.vendors.map((vendor) => (
- <td key={vendor.vendorId} className={cn(
- "p-2",
- vendor.isSelected && "bg-blue-50"
- )}>
- <div className="flex items-center gap-2">
- {vendor.vendorConditions.currency || vendor.buyerConditions.currency}
- {vendor.vendorConditions.currency !== vendor.buyerConditions.currency && (
- <Badge variant="outline" className="text-xs">변경</Badge>
- )}
- </div>
- </td>
- ))}
+ <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>
+ );
+ })}
</tr>
{/* 지급조건 */}
<tr>
- <td className="p-2 font-medium">지급조건</td>
- <td className="p-2">
+ <td className="p-3 font-medium">지급조건</td>
+ <td className="p-3">
<TooltipProvider>
<Tooltip>
- <TooltipTrigger>
+ <TooltipTrigger className="cursor-help border-b border-dashed">
{data.vendors[0]?.buyerConditions.paymentTermsCode}
</TooltipTrigger>
<TooltipContent>
@@ -783,327 +765,298 @@ export function QuotationCompareView({ data }: QuotationCompareViewProps) {
</Tooltip>
</TooltipProvider>
</td>
- {data.vendors.map((vendor) => (
- <td key={vendor.vendorId} className={cn(
- "p-2",
- vendor.isSelected && "bg-blue-50"
- )}>
- <div className="flex items-center gap-2">
- <TooltipProvider>
- <Tooltip>
- <TooltipTrigger>
- {vendor.vendorConditions.paymentTermsCode || vendor.buyerConditions.paymentTermsCode}
- </TooltipTrigger>
- <TooltipContent>
- {vendor.vendorConditions.paymentTermsDesc || vendor.buyerConditions.paymentTermsDesc}
- </TooltipContent>
- </Tooltip>
- </TooltipProvider>
- {vendor.vendorConditions.paymentTermsCode &&
- vendor.vendorConditions.paymentTermsCode !== vendor.buyerConditions.paymentTermsCode && (
- <Badge variant="outline" className="text-xs">변경</Badge>
- )}
- </div>
- </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">
+ <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>
+ );
+ })}
</tr>
- {/* 나머지 조건들도 동일한 패턴으로 처리 */}
- </tbody>
- </table>
- </CardContent>
- </Card>
- </TabsContent>
-
- {/* 아이템별 비교 */}
- <TabsContent value="items" className="space-y-4">
- <Card>
- <CardHeader>
- <CardTitle>PR 아이템별 가격 비교</CardTitle>
- </CardHeader>
- <CardContent>
- <div className="space-y-2">
- {data.prItems.map((item) => (
- <Collapsible
- key={item.prItemId}
- open={expandedItems.has(item.prItemId)}
- onOpenChange={() => toggleItemExpansion(item.prItemId)}
- >
- <div className="border rounded-lg">
- <CollapsibleTrigger className="w-full p-4 hover:bg-gray-50 transition-colors">
- <div className="flex items-center justify-between">
- <div className="flex items-center gap-4 text-left">
- <div className="flex items-center gap-2">
- {expandedItems.has(item.prItemId) ? (
- <ChevronUp className="h-4 w-4" />
- ) : (
- <ChevronDown className="h-4 w-4" />
+ {/* 인코텀즈 */}
+ <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">
+ <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>
)}
- <Package className="h-4 w-4 text-muted-foreground" />
- </div>
- <div>
- <p className="font-medium">{item.materialDescription}</p>
- <p className="text-sm text-muted-foreground">
- {item.materialCode} • {item.prNo} • {item.requestedQuantity} {item.uom}
- </p>
- </div>
</div>
- <div className="text-right">
- <p className="text-sm text-muted-foreground">단가 범위</p>
- <p className="font-semibold">
- {formatAmount(item.priceAnalysis.lowestPrice)} ~ {formatAmount(item.priceAnalysis.highestPrice)}
- </p>
- </div>
- </div>
- </CollapsibleTrigger>
-
- <CollapsibleContent>
- <div className="p-4 pt-0">
- <table className="w-full">
- <thead>
- <tr className="border-b text-sm">
- <th className="text-left p-2">벤더</th>
- <th className="text-right p-2">단가</th>
- <th className="text-right p-2">총액</th>
- <th className="text-right p-2">수량</th>
- <th className="text-left p-2">납기</th>
- <th className="text-left p-2">제조사</th>
- <th className="text-center p-2">순위</th>
- </tr>
- </thead>
- <tbody className="divide-y">
- {item.vendorQuotes.map((quote) => (
- <tr key={quote.vendorId} className="text-sm">
- <td className="p-2 font-medium">{quote.vendorName}</td>
- <td className="p-2 text-right">
- {formatAmount(quote.unitPrice, quote.currency)}
- </td>
- <td className="p-2 text-right">
- {formatAmount(quote.totalPrice, quote.currency)}
- </td>
- <td className="p-2 text-right">{quote.quotedQuantity}</td>
- <td className="p-2">
- {quote.deliveryDate
- ? format(new Date(quote.deliveryDate), "yyyy-MM-dd")
- : quote.leadTime
- ? `${quote.leadTime}일`
- : "-"}
- </td>
- <td className="p-2">
- {quote.manufacturer && (
- <div>
- <p>{quote.manufacturer}</p>
- {quote.modelNo && (
- <p className="text-xs text-muted-foreground">{quote.modelNo}</p>
- )}
- </div>
- )}
- </td>
- <td className="p-2 text-center">
- <Badge className={cn("", getRankColor(quote.priceRank || 0))}>
- #{quote.priceRank}
- </Badge>
- </td>
- </tr>
- ))}
- </tbody>
- </table>
-
- {/* 가격 분석 요약 */}
- <div className="mt-4 p-3 bg-gray-50 rounded-lg">
- <div className="grid grid-cols-4 gap-4 text-sm">
- <div>
- <p className="text-muted-foreground">평균 단가</p>
- <p className="font-semibold">
- {formatAmount(item.priceAnalysis.averagePrice)}
- </p>
- </div>
- <div>
- <p className="text-muted-foreground">가격 편차</p>
- <p className="font-semibold">
- ±{formatAmount(item.priceAnalysis.priceVariance)}
- </p>
- </div>
- <div>
- <p className="text-muted-foreground">최저가 업체</p>
- <p className="font-semibold">
- {item.vendorQuotes.find(q => q.priceRank === 1)?.vendorName}
- </p>
- </div>
- <div>
- <p className="text-muted-foreground">가격 차이</p>
- <p className="font-semibold">
- {((item.priceAnalysis.highestPrice - item.priceAnalysis.lowestPrice) /
- item.priceAnalysis.lowestPrice * 100).toFixed(1)}%
- </p>
- </div>
- </div>
- </div>
- </div>
- </CollapsibleContent>
- </div>
- </Collapsible>
- ))}
- </div>
- </CardContent>
- </Card>
- </TabsContent>
+ </td>
+ );
+ })}
+ </tr>
- {/* 상세 분석 */}
- <TabsContent value="analysis" className="space-y-4">
- <div className="grid grid-cols-1 md:grid-cols-2 gap-4">
- {/* 위험 요소 분석 */}
- <Card>
- <CardHeader>
- <CardTitle className="flex items-center gap-2">
- <AlertTriangle className="h-5 w-5 text-orange-500" />
- 위험 요소 분석
- </CardTitle>
- </CardHeader>
- <CardContent>
- <div className="space-y-3">
- {data.vendors.map((vendor) => {
- if (!vendor.conditionDifferences.hasDifferences) return null;
-
- return (
- <div key={vendor.vendorId} className="p-3 border rounded-lg">
- <p className="font-medium mb-2">{vendor.vendorName}</p>
- {vendor.conditionDifferences.criticalDifferences.length > 0 && (
- <div className="space-y-1 mb-2">
- <p className="text-xs font-medium text-red-600">중요 차이점:</p>
- {vendor.conditionDifferences.criticalDifferences.map((diff, idx) => (
- <p key={idx} className="text-xs text-red-600 pl-2">• {diff}</p>
- ))}
- </div>
- )}
- {vendor.conditionDifferences.differences.length > 0 && (
- <div className="space-y-1">
- <p className="text-xs font-medium text-orange-600">일반 차이점:</p>
- {vendor.conditionDifferences.differences.map((diff, idx) => (
- <p key={idx} className="text-xs text-orange-600 pl-2">• {diff}</p>
- ))}
+ {/* 선적지 */}
+ <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>
- )}
- </div>
- );
- })}
- </div>
- </CardContent>
- </Card>
-
- {/* 추천 사항 */}
- <Card>
- <CardHeader>
- <CardTitle className="flex items-center gap-2">
- <Info className="h-5 w-5 text-blue-500" />
- 선정 추천
- </CardTitle>
- </CardHeader>
- <CardContent>
- <div className="space-y-4">
- {/* 가격 기준 추천 */}
- <div className="p-3 bg-green-50 border border-green-200 rounded-lg">
- <p className="font-medium text-green-800 mb-1">가격 우선 선정</p>
- <p className="text-sm text-green-700">
- {data.vendors[0]?.vendorName} - {formatAmount(data.vendors[0]?.totalAmount || 0)}
- </p>
- {data.vendors[0]?.conditionDifferences.hasDifferences && (
- <p className="text-xs text-orange-600 mt-1">
- ⚠️ 조건 차이 검토 필요
- </p>
- )}
- </div>
+ </td>
+ );
+ })}
+ </tr>
- {/* 조건 준수 기준 추천 */}
- <div className="p-3 bg-blue-50 border border-blue-200 rounded-lg">
- <p className="font-medium text-blue-800 mb-1">조건 준수 우선 선정</p>
- {(() => {
- const compliantVendor = data.vendors.find(v => !v.conditionDifferences.hasDifferences);
- if (compliantVendor) {
- return (
- <div>
- <p className="text-sm text-blue-700">
- {compliantVendor.vendorName} - {formatAmount(compliantVendor.totalAmount)}
- </p>
- <p className="text-xs text-blue-600 mt-1">
- 모든 조건 충족 (가격 순위: #{compliantVendor.rank})
- </p>
- </div>
- );
- }
+ {/* 하역지 */}
+ <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 (
- <p className="text-sm text-blue-700">
- 모든 조건을 충족하는 벤더 없음
- </p>
+ <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>
);
- })()}
- </div>
+ })}
+ </tr>
- {/* 균형 추천 */}
- <div className="p-3 bg-purple-50 border border-purple-200 rounded-lg">
- <p className="font-medium text-purple-800 mb-1">균형 선정 (추천)</p>
- {(() => {
- // 가격 순위와 조건 차이를 고려한 점수 계산
- const scoredVendors = data.vendors.map(v => ({
- ...v,
- score: (v.rank || 10) + v.conditionDifferences.criticalDifferences.length * 3 +
- v.conditionDifferences.differences.length
- }));
- scoredVendors.sort((a, b) => a.score - b.score);
- const recommended = scoredVendors[0];
+ {/* 납기일 */}
+ <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>
+ );
+ })}
+ </tr>
+ {/* 세금조건 */}
+ <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 (
- <div>
- <p className="text-sm text-purple-700">
- {recommended.vendorName} - {formatAmount(recommended.totalAmount)}
- </p>
- <p className="text-xs text-purple-600 mt-1">
- 가격 순위 #{recommended.rank}, 조건 차이 최소화
- </p>
- </div>
+ <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>
);
- })()}
- </div>
- </div>
- </CardContent>
- </Card>
- </div>
+ })}
+ </tr>
- {/* 벤더별 비고사항 */}
- {data.vendors.some(v => v.generalRemark || v.technicalProposal) && (
- <Card>
- <CardHeader>
- <CardTitle>벤더 제안사항 및 비고</CardTitle>
- </CardHeader>
- <CardContent>
- <div className="space-y-4">
- {data.vendors.map((vendor) => {
- if (!vendor.generalRemark && !vendor.technicalProposal) return null;
-
- return (
- <div key={vendor.vendorId} className="border rounded-lg p-4">
- <p className="font-medium mb-2">{vendor.vendorName}</p>
- {vendor.generalRemark && (
- <div className="mb-2">
- <p className="text-sm font-medium text-muted-foreground">일반 비고:</p>
- <p className="text-sm">{vendor.generalRemark}</p>
+ {/* 계약기간 */}
+ <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>
- )}
- {vendor.technicalProposal && (
+ </td>
+ );
+ })}
+ </tr>
+ </tbody>
+ </table>
+ </CardContent>
+ </Card>
+ </TabsContent>
+ </Tabs>
+
+ {/* 품목별 상세 정보 다이얼로그 */}
+ <Dialog open={showItemDetailsDialog} onOpenChange={setShowItemDetailsDialog}>
+ <DialogContent className="max-w-6xl max-h-[90vh] overflow-y-auto">
+ <DialogHeader>
+ <DialogTitle>
+ {selectedVendorName} - {selectedResponse?.responseVersion}차 품목별 견적 상세
+ </DialogTitle>
+ </DialogHeader>
+
+ <div className="overflow-x-auto">
+ <table className="w-full">
+ <thead>
+ <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-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>
+ </tr>
+ </thead>
+ <tbody className="divide-y">
+ {selectedResponse?.quotationItems?.map((quoteItem) => {
+ const prItem = data.prItems.find(item => item.prItemId === quoteItem.prItemId);
+ if (!prItem) return null;
+
+ 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>
+ <p className="text-xs text-muted-foreground">
+ {prItem.prNo} • {prItem.prItem}
+ </p>
+ </td>
+ <td className="p-3 text-right">
+ {quoteItem.quantity} {prItem.uom}
+ </td>
+ <td className="p-3 text-right font-medium">
+ {formatAmount(quoteItem.unitPrice, quoteItem.currency)}
+ </td>
+ <td className="p-3 text-right font-bold">
+ {formatAmount(quoteItem.totalPrice, quoteItem.currency)}
+ </td>
+ <td className="p-3 text-sm">
+ {quoteItem.deliveryDate
+ ? format(new Date(quoteItem.deliveryDate), "yyyy-MM-dd")
+ : quoteItem.leadTime
+ ? `${quoteItem.leadTime}일`
+ : "-"}
+ </td>
+ <td className="p-3 text-sm">
+ {quoteItem.manufacturer ? (
<div>
- <p className="text-sm font-medium text-muted-foreground">기술 제안:</p>
- <p className="text-sm">{vendor.technicalProposal}</p>
+ <p>{quoteItem.manufacturer}</p>
+ {quoteItem.modelNo && (
+ <p className="text-xs text-muted-foreground">{quoteItem.modelNo}</p>
+ )}
</div>
- )}
- </div>
- );
- })}
- </div>
- </CardContent>
- </Card>
+ ) : "-"}
+ </td>
+ </tr>
+ );
+ })}
+ </tbody>
+ </table>
+ </div>
+
+ {/* 비고사항 */}
+ {(selectedResponse?.generalRemark || selectedResponse?.technicalProposal) && (
+ <div className="mt-6 pt-6 border-t">
+ <h4 className="font-semibold mb-3">비고사항</h4>
+ <div className="space-y-3">
+ {selectedResponse.generalRemark && (
+ <div>
+ <p className="text-sm font-medium text-muted-foreground">일반 비고:</p>
+ <p className="text-sm">{selectedResponse.generalRemark}</p>
+ </div>
+ )}
+ {selectedResponse.technicalProposal && (
+ <div>
+ <p className="text-sm font-medium text-muted-foreground">기술 제안:</p>
+ <p className="text-sm">{selectedResponse.technicalProposal}</p>
+ </div>
+ )}
+ </div>
+ </div>
)}
- </TabsContent>
- </Tabs>
+ </DialogContent>
+ </Dialog>
+
{/* 업체 선정 모달 */}
{showSelectionDialog && (
@@ -1113,57 +1066,62 @@ export function QuotationCompareView({ data }: QuotationCompareViewProps) {
{hasSelection ? "업체 재선정 확인" : "업체 선정 확인"}
</h3>
- {selectedVendorId && (
- <div className="space-y-4">
- <div className="rounded-lg border p-4">
- <div className="space-y-2">
- <div className="flex justify-between">
- <span className="text-sm font-medium">선정 업체</span>
- <span className="text-sm font-bold">
- {data.vendors.find(v => v.vendorId === parseInt(selectedVendorId))?.vendorName}
- </span>
- </div>
- <div className="flex justify-between">
- <span className="text-sm font-medium">견적 금액</span>
- <span className="text-sm">
- {formatAmount(
- data.vendors.find(v => v.vendorId === parseInt(selectedVendorId))?.totalAmount || 0,
- data.vendors.find(v => v.vendorId === parseInt(selectedVendorId))?.currency
- )}
- </span>
- </div>
- <div className="flex justify-between">
- <span className="text-sm font-medium">가격 순위</span>
- <span className="text-sm">
- #{data.vendors.find(v => v.vendorId === parseInt(selectedVendorId))?.rank || 0}
- </span>
+ {selectedVendorId && (() => {
+ const vendor = data.vendors.find(v => v.vendorId === parseInt(selectedVendorId));
+ const latestResponse = vendor?.responses[0];
+
+ return (
+ <div className="space-y-4">
+ <div className="rounded-lg border p-4">
+ <div className="space-y-2">
+ <div className="flex justify-between">
+ <span className="text-sm font-medium">선정 업체</span>
+ <span className="text-sm font-bold">
+ {vendor?.vendorName}
+ </span>
+ </div>
+ <div className="flex justify-between">
+ <span className="text-sm font-medium">견적 금액</span>
+ <span className="text-sm">
+ {formatAmount(
+ latestResponse?.totalAmount || 0,
+ latestResponse?.currency || "USD"
+ )}
+ </span>
+ </div>
+ <div className="flex justify-between">
+ <span className="text-sm font-medium">가격 순위</span>
+ <span className="text-sm">
+ #{latestResponse?.rank || 0}
+ </span>
+ </div>
+ {latestResponse?.conditionDifferences.hasDifferences && (
+ <Alert className="mt-2">
+ <AlertCircle className="h-4 w-4" />
+ <AlertDescription>
+ 제시 조건과 차이가 있습니다. 선정 사유를 명확히 기재해주세요.
+ </AlertDescription>
+ </Alert>
+ )}
</div>
- {data.vendors.find(v => v.vendorId === parseInt(selectedVendorId))?.conditionDifferences.hasDifferences && (
- <Alert className="mt-2">
- <AlertCircle className="h-4 w-4" />
- <AlertDescription>
- 제시 조건과 차이가 있습니다. 선정 사유를 명확히 기재해주세요.
- </AlertDescription>
- </Alert>
- )}
</div>
- </div>
- <div className="space-y-2">
- <label htmlFor="selection-reason" className="text-sm font-medium">
- 선정 사유 *
- </label>
- <textarea
- id="selection-reason"
- className="w-full min-h-[100px] p-3 border rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
- placeholder="업체 선정 사유를 입력해주세요..."
- value={selectionReason}
- onChange={(e) => setSelectionReason(e.target.value)}
- required
- />
+ <div className="space-y-2">
+ <label htmlFor="selection-reason" className="text-sm font-medium">
+ 선정 사유 *
+ </label>
+ <textarea
+ id="selection-reason"
+ className="w-full min-h-[100px] p-3 border rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
+ placeholder="업체 선정 사유를 입력해주세요..."
+ value={selectionReason}
+ onChange={(e) => setSelectionReason(e.target.value)}
+ required
+ />
+ </div>
</div>
- </div>
- )}
+ );
+ })()}
<div className="flex justify-end gap-2 mt-6">
<Button
@@ -1527,7 +1485,7 @@ export function QuotationCompareView({ data }: QuotationCompareViewProps) {
setSelectedGeneralContractType("");
setContractStartDate("");
setContractEndDate("");
- setBiddingContractType("");
+ setBiddingContractType("unit_price");
setBiddingType("");
setAwardCount("single");
setBiddingStartDate("");