diff options
Diffstat (limited to 'lib/rfq-last')
| -rw-r--r-- | lib/rfq-last/compare-action.ts | 542 | ||||
| -rw-r--r-- | lib/rfq-last/contract-actions.ts | 297 | ||||
| -rw-r--r-- | lib/rfq-last/quotation-compare-view.tsx | 808 | ||||
| -rw-r--r-- | lib/rfq-last/service.ts | 9 | ||||
| -rw-r--r-- | lib/rfq-last/vendor/rfq-vendor-table.tsx | 29 |
5 files changed, 1480 insertions, 205 deletions
diff --git a/lib/rfq-last/compare-action.ts b/lib/rfq-last/compare-action.ts index 5d210631..2be594e9 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 } from "drizzle-orm"; +import { eq, and, inArray,ne } from "drizzle-orm"; import { rfqsLast, rfqLastDetails, @@ -10,8 +10,13 @@ import { rfqLastVendorQuotationItems, vendors, paymentTerms, - incoterms, + incoterms,vendorSelections } from "@/db/schema"; +import { revalidatePath } from "next/cache"; +import { getServerSession } from "next-auth/next" +import { authOptions } from "@/app/api/auth/[...nextauth]/route" + +// ===== 타입 정의 ===== export interface ComparisonData { rfqInfo: { @@ -55,7 +60,7 @@ export interface VendorComparison { totalAmount: number; currency: string; rank?: number; - priceVariance?: number; // 평균 대비 차이 % + priceVariance?: number; // 구매자 제시 조건 buyerConditions: { @@ -69,8 +74,6 @@ export interface VendorComparison { taxCode?: string; placeOfShipping?: string; placeOfDestination?: string; - - // 추가 조건 firstYn: boolean; firstDescription?: string; sparepartYn: boolean; @@ -90,8 +93,6 @@ export interface VendorComparison { taxCode?: string; placeOfShipping?: string; placeOfDestination?: string; - - // 추가 조건 응답 firstAcceptance?: "수용" | "부분수용" | "거부"; firstDescription?: string; sparepartAcceptance?: "수용" | "부분수용" | "거부"; @@ -104,12 +105,28 @@ export interface VendorComparison { conditionDifferences: { hasDifferences: boolean; differences: string[]; - criticalDifferences: string[]; // 중요한 차이점 + criticalDifferences: string[]; }; // 비고 generalRemark?: string; technicalProposal?: string; + + // 선정 관련 정보 + isSelected?: boolean; + selectionDate?: Date | null; + selectionReason?: string; + selectedBy?: number; + selectedByName?: string; + selectionApprovalStatus?: "대기" | "승인" | "반려" | null; + selectionApprovedBy?: number; + selectionApprovedAt?: Date | null; + selectionApprovalComment?: string; + + // 계약 관련 정보 추가 + contractStatus?: string; + contractNo?: string; + contractCreatedAt?: Date | null; } export interface PrItemComparison { @@ -143,10 +160,12 @@ export interface PrItemComparison { lowestPrice: number; highestPrice: number; averagePrice: number; - priceVariance: number; // 표준편차 + priceVariance: number; }; } +// ===== 메인 조회 함수 ===== + export async function getComparisonData( rfqId: number, vendorIds: number[] @@ -159,8 +178,6 @@ export async function getComparisonData( rfqCode: rfqsLast.rfqCode, rfqTitle: rfqsLast.rfqTitle, rfqType: rfqsLast.rfqType, - // projectCode: rfqsLast.projectCode, - // projectName: rfqsLast.projectName, dueDate: rfqsLast.dueDate, packageNo: rfqsLast.packageNo, packageName: rfqsLast.packageName, @@ -171,7 +188,7 @@ export async function getComparisonData( if (!rfqData[0]) return null; - // 2. 벤더별 정보 및 응답 조회 + // 2. 벤더별 정보, 응답, 선정 정보 조회 const vendorData = await db .select({ // 벤더 정보 @@ -197,6 +214,21 @@ export async function getComparisonData( buyerSparepartDescription: rfqLastDetails.sparepartDescription, buyerMaterialPriceRelatedYn: rfqLastDetails.materialPriceRelatedYn, + // 선정 관련 정보 + isSelected: rfqLastDetails.isSelected, + selectionDate: rfqLastDetails.selectionDate, + selectionReason: rfqLastDetails.selectionReason, + selectedBy: rfqLastDetails.selectedBy, + selectionApprovalStatus: rfqLastDetails.selectionApprovalStatus, + selectionApprovedBy: rfqLastDetails.selectionApprovedBy, + selectionApprovedAt: rfqLastDetails.selectionApprovedAt, + selectionApprovalComment: rfqLastDetails.selectionApprovalComment, + + // 계약 관련 정보 + contractStatus: rfqLastDetails.contractStatus, + contractNo: rfqLastDetails.contractNo, + contractCreatedAt: rfqLastDetails.contractCreatedAt, + // 벤더 응답 responseId: rfqLastVendorResponses.id, participationStatus: rfqLastVendorResponses.participationStatus, @@ -247,7 +279,19 @@ export async function getComparisonData( ) .where(inArray(vendors.id, vendorIds)); - // 3. Payment Terms와 Incoterms 설명 조회 + // 3. 선정자 이름 조회 (선정된 업체가 있는 경우) + const selectedVendor = vendorData.find(v => v.isSelected); + let selectedByName = ""; + if (selectedVendor?.selectedBy) { + const [user] = await db + .select({ name: users.name }) + .from(users) + .where(eq(users.id, selectedVendor.selectedBy)) + .limit(1); + selectedByName = user?.name || ""; + } + + // 4. Payment Terms와 Incoterms 설명 조회 const paymentTermsData = await db .select({ code: paymentTerms.code, @@ -269,7 +313,7 @@ export async function getComparisonData( incotermsData.map(ic => [ic.code, ic.description]) ); - // 4. PR Items 조회 + // 5. PR Items 조회 const prItems = await db .select({ id: rfqPrItems.id, @@ -284,7 +328,7 @@ export async function getComparisonData( .from(rfqPrItems) .where(eq(rfqPrItems.rfqsLastId, rfqId)); - // 5. 벤더별 견적 아이템 조회 + // 6. 벤더별 견적 아이템 조회 const quotationItems = await db .select({ vendorResponseId: rfqLastVendorQuotationItems.vendorResponseId, @@ -309,7 +353,7 @@ export async function getComparisonData( ) ); - // 6. 데이터 가공 및 분석 + // 7. 데이터 가공 및 분석 const validAmounts = vendorData .map(v => v.totalAmount) .filter(a => a != null && a > 0); @@ -318,7 +362,7 @@ export async function getComparisonData( 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[] = []; @@ -413,16 +457,32 @@ export async function getComparisonData( generalRemark: v.generalRemark, technicalProposal: v.technicalProposal, + + // 선정 관련 정보 + isSelected: v.isSelected || false, + selectionDate: v.selectionDate, + selectionReason: v.selectionReason, + selectedBy: v.selectedBy, + selectedByName: v.isSelected ? selectedByName : undefined, + selectionApprovalStatus: v.selectionApprovalStatus, + selectionApprovedBy: v.selectionApprovedBy, + selectionApprovedAt: v.selectionApprovedAt, + selectionApprovalComment: v.selectionApprovalComment, + + // 계약 관련 정보 + contractStatus: v.contractStatus, + contractNo: v.contractNo, + contractCreatedAt: v.contractCreatedAt, }; }); - // 가격 순위 계산 + // 9. 가격 순위 계산 vendorComparisons.sort((a, b) => a.totalAmount - b.totalAmount); vendorComparisons.forEach((v, index) => { v.rank = index + 1; }); - // PR 아이템별 비교 데이터 구성 + // 10. PR 아이템별 비교 데이터 구성 const prItemComparisons: PrItemComparison[] = prItems.map(item => { const itemQuotes = quotationItems .filter(q => q.prItemId === item.id) @@ -477,7 +537,23 @@ export async function getComparisonData( }; }); - // 최종 데이터 구성 + console.log({ + rfqInfo: rfqData[0], + vendors: vendorComparisons, + prItems: prItemComparisons, + summary: { + lowestBidder: vendorComparisons[0]?.vendorName || "", + highestBidder: vendorComparisons[vendorComparisons.length - 1]?.vendorName || "", + priceRange: { + min: minAmount, + max: maxAmount, + average: avgAmount, + }, + currency: vendorComparisons[0]?.currency || "USD", + }, + }); + + // 11. 최종 데이터 반환 return { rfqInfo: rfqData[0], vendors: vendorComparisons, @@ -497,4 +573,428 @@ export async function getComparisonData( console.error("견적 비교 데이터 조회 실패:", error); return null; } -}
\ No newline at end of file +} + +interface SelectVendorParams { + rfqId: number; + vendorId: number; + vendorName: string; + vendorCode: string; + totalAmount: number; + currency: string; + selectionReason: string; + priceRank: number; + hasConditionDifferences: boolean; + criticalDifferences: string[]; + userId: number; // 현재 사용자 ID +} + +export async function selectVendor(params: SelectVendorParams) { + try { + // 트랜잭션 시작 + const result = await db.transaction(async (tx) => { + // 1. RFQ 상태 확인 + const [rfq] = await tx + .select() + .from(rfqsLast) + .where(eq(rfqsLast.id, params.rfqId)); + + if (!rfq) { + throw new Error("RFQ를 찾을 수 없습니다."); + } + + if (rfq.status === "최종업체선정") { + throw new Error("이미 업체가 선정된 RFQ입니다."); + } + + // 2. 기존에 선정된 업체가 있다면 선정 해제 + await tx + .update(rfqLastDetails) + .set({ + isSelected: false, + updatedAt: new Date(), + updatedBy: params.userId + }) + .where( + and( + eq(rfqLastDetails.rfqsLastId, params.rfqId), + eq(rfqLastDetails.isSelected, true) + ) + ); + + // 3. 새로운 업체 선정 + const [selection] = await tx + .update(rfqLastDetails) + .set({ + isSelected: true, + selectionDate: new Date(), + selectionReason: params.selectionReason, + selectedBy: params.userId, + totalAmount: params.totalAmount.toString(), + priceRank: params.priceRank, + updatedAt: new Date(), + updatedBy: params.userId, + }) + .where( + and( + eq(rfqLastDetails.rfqsLastId, params.rfqId), + eq(rfqLastDetails.vendorsId, params.vendorId), + eq(rfqLastDetails.isLatest, true) + ) + ) + .returning(); + + if (!selection) { + throw new Error("업체 견적 정보를 찾을 수 없습니다."); + } + + // 4. RFQ 상태 업데이트 + await tx + .update(rfqsLast) + .set({ + status: "최종업체선정", + updatedAt: new Date(), + }) + .where(eq(rfqsLast.id, params.rfqId)); + + // 5. 다른 업체들의 견적은 미선정 상태로 명시적 업데이트 + await tx + .update(rfqLastDetails) + .set({ + isSelected: false, + updatedAt: new Date(), + }) + .where( + and( + eq(rfqLastDetails.rfqsLastId, params.rfqId), + eq(rfqLastDetails.isLatest, true), + // NOT equal to selected vendor + // Drizzle에서는 ne (not equal) 연산자를 사용 + ne(rfqLastDetails.vendorsId, params.vendorId) + ) + ); + + return selection; + }); + + // 캐시 무효화 + revalidatePath(`/evcp/rfq-last/${params.rfqId}`); + revalidatePath("/evcp/rfq"); + + return { + success: true, + data: result, + redirectUrl: `/evcp/rfq-last/${params.rfqId}/selection-complete` + }; + + } catch (error) { + console.error("업체 선정 오류:", error); + return { + success: false, + error: error instanceof Error ? error.message : "업체 선정 중 오류가 발생했습니다." + }; + } +} + +// 업체 선정 취소 +export async function cancelVendorSelection(rfqId: number, cancelReason: string) { + try { + const session = await getServerSession(authOptions) + if (!session?.user) { + throw new Error("인증이 필요합니다.") + } + + const userId = Number(session.user.id) + + + await db.transaction(async (tx) => { + // 선정 정보 업데이트 (취소 사유 기록) + const [cancelled] = await tx + .update(rfqLastDetails) + .set({ + isSelected: false, + selectionDate: null, + selectionReason: null, + selectedBy: null, + cancelReason: cancelReason, + selectionApprovalStatus: null, + selectionApprovedBy: null, + selectionApprovedAt: null, + selectionApprovalComment: null, + updatedAt: new Date(), + updatedBy: userId, + }) + .where( + and( + eq(rfqLastDetails.rfqsLastId, rfqId), + eq(rfqLastDetails.isSelected, true) + ) + ) + .returning(); + + if (!cancelled) { + throw new Error("선정된 업체를 찾을 수 없습니다."); + } + + // RFQ 상태 되돌리기 + await tx + .update(rfqsLast) + .set({ + status: "견적접수", + updatedAt: new Date(), + }) + .where(eq(rfqsLast.id, rfqId)); + }); + + revalidatePath(`/evcp/rfq-last/${rfqId}`); + revalidatePath("/evcp/rfq-last"); + + return { success: true }; + } catch (error) { + console.error("업체 선정 취소 오류:", error); + return { + success: false, + error: error instanceof Error ? error.message : "업체 선정 취소 중 오류가 발생했습니다." + }; + } +} +// 선정된 업체 정보 조회 +export async function getSelectedVendor(rfqId: number) { + try { + const [selected] = await db + .select({ + detail: rfqLastDetails, + vendor: vendors, + }) + .from(rfqLastDetails) + .leftJoin(vendors, eq(rfqLastDetails.vendorsId, vendors.id)) + .where( + and( + eq(rfqLastDetails.rfqsLastId, rfqId), + eq(rfqLastDetails.isSelected, true), + eq(rfqLastDetails.isLatest, true) + ) + ) + .limit(1); + + return { + success: true, + data: selected + }; + } catch (error) { + console.error("선정 업체 조회 오류:", error); + return { + success: false, + error: error instanceof Error ? error.message : "선정 업체 조회 중 오류가 발생했습니다." + }; + } +} + +// 선정 승인 요청 +export async function requestSelectionApproval( + rfqId: number, + vendorId: number, + userId: number +) { + try { + const [updated] = await db + .update(rfqLastDetails) + .set({ + selectionApprovalStatus: "대기", + updatedAt: new Date(), + updatedBy: userId, + }) + .where( + and( + eq(rfqLastDetails.rfqsLastId, rfqId), + eq(rfqLastDetails.vendorsId, vendorId), + eq(rfqLastDetails.isSelected, true), + eq(rfqLastDetails.isLatest, true) + ) + ) + .returning(); + + if (!updated) { + throw new Error("선정된 업체를 찾을 수 없습니다."); + } + + revalidatePath(`/evcp/rfq-last/${rfqId}`); + + return { + success: true, + data: updated + }; + } catch (error) { + console.error("선정 승인 요청 오류:", error); + return { + success: false, + error: error instanceof Error ? error.message : "선정 승인 요청 중 오류가 발생했습니다." + }; + } +} + +// 선정 승인/반려 처리 +export async function processSelectionApproval( + rfqId: number, + vendorId: number, + action: "승인" | "반려", + comment: string, + approverId: number +) { + try { + await db.transaction(async (tx) => { + // 선정 승인 정보 업데이트 + const [updated] = await tx + .update(rfqLastDetails) + .set({ + selectionApprovalStatus: action, + selectionApprovedBy: approverId, + selectionApprovedAt: new Date(), + selectionApprovalComment: comment, + updatedAt: new Date(), + updatedBy: approverId, + }) + .where( + and( + eq(rfqLastDetails.rfqsLastId, rfqId), + eq(rfqLastDetails.vendorsId, vendorId), + eq(rfqLastDetails.isSelected, true), + eq(rfqLastDetails.isLatest, true) + ) + ) + .returning(); + + if (!updated) { + throw new Error("선정된 업체를 찾을 수 없습니다."); + } + + // 승인된 경우 RFQ 상태 업데이트 + if (action === "승인") { + await tx + .update(rfqsLast) + .set({ + status: "계약 진행중", + updatedAt: new Date(), + }) + .where(eq(rfqsLast.id, rfqId)); + } + }); + + revalidatePath(`/evcp/rfq-last/${rfqId}`); + revalidatePath("/evcp/rfq"); + + return { + success: true, + message: action === "승인" + ? "업체 선정이 승인되었습니다." + : "업체 선정이 반려되었습니다." + }; + } catch (error) { + console.error("선정 승인 처리 오류:", error); + return { + success: false, + error: error instanceof Error ? error.message : "선정 승인 처리 중 오류가 발생했습니다." + }; + } +} + +// 가격 순위 업데이트 (견적 제출 후 자동 실행) +export async function updatePriceRanks(rfqId: number) { + try { + // 해당 RFQ의 모든 최신 견적 조회 + const quotes = await db + .select({ + id: rfqLastDetails.id, + vendorsId: rfqLastDetails.vendorsId, + totalAmount: rfqLastDetails.totalAmount, + }) + .from(rfqLastDetails) + .where( + and( + eq(rfqLastDetails.rfqsLastId, rfqId), + eq(rfqLastDetails.isLatest, true) + ) + ) + .orderBy(asc(rfqLastDetails.totalAmount)); + + // 순위 업데이트 + await db.transaction(async (tx) => { + for (let i = 0; i < quotes.length; i++) { + await tx + .update(rfqLastDetails) + .set({ + priceRank: i + 1, + updatedAt: new Date() + }) + .where(eq(rfqLastDetails.id, quotes[i].id)); + } + }); + + return { + success: true, + message: "가격 순위가 업데이트되었습니다." + }; + } catch (error) { + console.error("가격 순위 업데이트 오류:", error); + return { + success: false, + error: error instanceof Error ? error.message : "가격 순위 업데이트 중 오류가 발생했습니다." + }; + } +} + +// RFQ의 모든 견적 상태 조회 +export async function getRfqQuotationStatus(rfqId: number) { + try { + const quotations = await db + .select({ + id: rfqLastDetails.id, + vendorId: rfqLastDetails.vendorsId, + vendorName: vendors.name, + vendorCode: vendors.code, + totalAmount: rfqLastDetails.totalAmount, + currency: rfqLastDetails.currency, + priceRank: rfqLastDetails.priceRank, + isSelected: rfqLastDetails.isSelected, + selectionDate: rfqLastDetails.selectionDate, + selectionReason: rfqLastDetails.selectionReason, + selectionApprovalStatus: rfqLastDetails.selectionApprovalStatus, + emailStatus: rfqLastDetails.emailStatus, + lastEmailSentAt: rfqLastDetails.lastEmailSentAt, + }) + .from(rfqLastDetails) + .leftJoin(vendors, eq(rfqLastDetails.vendorsId, vendors.id)) + .where( + and( + eq(rfqLastDetails.rfqsLastId, rfqId), + eq(rfqLastDetails.isLatest, true) + ) + ) + .orderBy(asc(rfqLastDetails.priceRank)); + + const selectedVendor = quotations.find(q => q.isSelected); + const totalQuotations = quotations.length; + const respondedQuotations = quotations.filter(q => q.totalAmount).length; + + return { + success: true, + data: { + quotations, + summary: { + total: totalQuotations, + responded: respondedQuotations, + pending: totalQuotations - respondedQuotations, + selected: selectedVendor ? 1 : 0, + selectedVendor: selectedVendor || null, + } + } + }; + } catch (error) { + console.error("RFQ 견적 상태 조회 오류:", error); + return { + success: false, + error: error instanceof Error ? error.message : "견적 상태 조회 중 오류가 발생했습니다." + }; + } +} + diff --git a/lib/rfq-last/contract-actions.ts b/lib/rfq-last/contract-actions.ts new file mode 100644 index 00000000..1144cf4f --- /dev/null +++ b/lib/rfq-last/contract-actions.ts @@ -0,0 +1,297 @@ +"use server"; + +import db from "@/db/db"; +import { rfqsLast, rfqLastDetails } from "@/db/schema"; +import { eq, and } from "drizzle-orm"; +import { revalidatePath } from "next/cache"; + +// ===== PO (SAP) 생성 ===== +interface CreatePOParams { + rfqId: number; + vendorId: number; + vendorName: string; + totalAmount: number; + currency: string; + selectionReason?: string; +} + +export async function createPO(params: CreatePOParams) { + try { + const userId = 1; // TODO: 실제 사용자 ID 가져오기 + + // 1. 선정된 업체 확인 + const [selectedVendor] = await db + .select() + .from(rfqLastDetails) + .where( + and( + eq(rfqLastDetails.rfqsLastId, params.rfqId), + eq(rfqLastDetails.vendorsId, params.vendorId), + eq(rfqLastDetails.isSelected, true), + eq(rfqLastDetails.isLatest, true) + ) + ); + + if (!selectedVendor) { + throw new Error("선정된 업체 정보를 찾을 수 없습니다."); + } + + // 2. SAP 연동 로직 (TODO: 실제 구현 필요) + // - SAP API 호출 + // - PO 번호 생성 + // - 아이템 정보 전송 + // - 결재 라인 설정 + + // 3. 계약 상태 업데이트 + await db.transaction(async (tx) => { + // rfqLastDetails에 계약 정보 업데이트 + await tx + .update(rfqLastDetails) + .set({ + contractStatus: "진행중", + contractCreatedAt: new Date(), + contractNo: `PO-${Date.now()}`, // TODO: 실제 PO 번호로 변경 + updatedAt: new Date(), + updatedBy: userId, + }) + .where( + and( + eq(rfqLastDetails.rfqsLastId, params.rfqId), + eq(rfqLastDetails.vendorsId, params.vendorId), + eq(rfqLastDetails.isSelected, true) + ) + ); + + // RFQ 상태 업데이트 + await tx + .update(rfqsLast) + .set({ + status: "PO 생성 완료", + updatedAt: new Date(), + }) + .where(eq(rfqsLast.id, params.rfqId)); + }); + + revalidatePath(`/rfq/${params.rfqId}`); + revalidatePath("/rfq"); + + return { + success: true, + message: "PO가 성공적으로 생성되었습니다.", + poNumber: `PO-${Date.now()}`, // TODO: 실제 PO 번호 반환 + }; + } catch (error) { + console.error("PO 생성 오류:", error); + return { + success: false, + error: error instanceof Error ? error.message : "PO 생성 중 오류가 발생했습니다." + }; + } +} + +// ===== 일반계약 생성 ===== +interface CreateGeneralContractParams { + rfqId: number; + vendorId: number; + vendorName: string; + totalAmount: number; + currency: string; + contractType?: string; + contractStartDate?: Date; + contractEndDate?: Date; + contractTerms?: string; +} + +export async function createGeneralContract(params: CreateGeneralContractParams) { + try { + const userId = 1; // TODO: 실제 사용자 ID 가져오기 + + // 1. 선정된 업체 확인 + const [selectedVendor] = await db + .select() + .from(rfqLastDetails) + .where( + and( + eq(rfqLastDetails.rfqsLastId, params.rfqId), + eq(rfqLastDetails.vendorsId, params.vendorId), + eq(rfqLastDetails.isSelected, true), + eq(rfqLastDetails.isLatest, true) + ) + ); + + if (!selectedVendor) { + throw new Error("선정된 업체 정보를 찾을 수 없습니다."); + } + + // 2. 계약 생성 로직 (TODO: 실제 구현 필요) + // - 계약서 템플릿 선택 + // - 계약 조건 설정 + // - 계약서 문서 생성 + // - 전자서명 프로세스 시작 + + // 3. 계약 상태 업데이트 + await db.transaction(async (tx) => { + // rfqLastDetails에 계약 정보 업데이트 + await tx + .update(rfqLastDetails) + .set({ + contractStatus: "진행중", + contractCreatedAt: new Date(), + contractNo: `CONTRACT-${Date.now()}`, // TODO: 실제 계약번호로 변경 + updatedAt: new Date(), + updatedBy: userId, + }) + .where( + and( + eq(rfqLastDetails.rfqsLastId, params.rfqId), + eq(rfqLastDetails.vendorsId, params.vendorId), + eq(rfqLastDetails.isSelected, true) + ) + ); + + // RFQ 상태 업데이트 + await tx + .update(rfqsLast) + .set({ + status: "일반계약 진행중", + updatedAt: new Date(), + }) + .where(eq(rfqsLast.id, params.rfqId)); + }); + + revalidatePath(`/rfq/${params.rfqId}`); + revalidatePath("/rfq"); + + return { + success: true, + message: "일반계약이 성공적으로 생성되었습니다.", + contractNumber: `CONTRACT-${Date.now()}`, // TODO: 실제 계약번호 반환 + }; + } catch (error) { + console.error("일반계약 생성 오류:", error); + return { + success: false, + error: error instanceof Error ? error.message : "일반계약 생성 중 오류가 발생했습니다." + }; + } +} + +// ===== 입찰 생성 ===== +interface CreateBiddingParams { + rfqId: number; + vendorId: number; + vendorName: string; + totalAmount: number; + currency: string; + biddingType?: string; // 공개입찰, 제한입찰 등 + biddingStartDate?: Date; + biddingEndDate?: Date; + biddingRequirements?: string; +} + +export async function createBidding(params: CreateBiddingParams) { + try { + const userId = 1; // TODO: 실제 사용자 ID 가져오기 + + // 1. 선정된 업체 확인 + const [selectedVendor] = await db + .select() + .from(rfqLastDetails) + .where( + and( + eq(rfqLastDetails.rfqsLastId, params.rfqId), + eq(rfqLastDetails.vendorsId, params.vendorId), + eq(rfqLastDetails.isSelected, true), + eq(rfqLastDetails.isLatest, true) + ) + ); + + if (!selectedVendor) { + throw new Error("선정된 업체 정보를 찾을 수 없습니다."); + } + + // 2. 입찰 생성 로직 (TODO: 실제 구현 필요) + // - 입찰 공고 생성 + // - 입찰 참가자격 설정 + // - 입찰 일정 등록 + // - 평가 기준 설정 + // - 입찰 시스템 등록 + + // 3. 입찰 상태 업데이트 + await db.transaction(async (tx) => { + // rfqLastDetails에 입찰 정보 업데이트 + await tx + .update(rfqLastDetails) + .set({ + contractStatus: "입찰진행중", + contractCreatedAt: new Date(), + contractNo: `BID-${Date.now()}`, // TODO: 실제 입찰번호로 변경 + updatedAt: new Date(), + updatedBy: userId, + }) + .where( + and( + eq(rfqLastDetails.rfqsLastId, params.rfqId), + eq(rfqLastDetails.vendorsId, params.vendorId), + eq(rfqLastDetails.isSelected, true) + ) + ); + + // RFQ 상태 업데이트 + await tx + .update(rfqsLast) + .set({ + status: "입찰 진행중", + updatedAt: new Date(), + }) + .where(eq(rfqsLast.id, params.rfqId)); + }); + + revalidatePath(`/rfq/${params.rfqId}`); + revalidatePath("/rfq"); + + return { + success: true, + message: "입찰이 성공적으로 생성되었습니다.", + biddingNumber: `BID-${Date.now()}`, // TODO: 실제 입찰번호 반환 + }; + } catch (error) { + console.error("입찰 생성 오류:", error); + return { + success: false, + error: error instanceof Error ? error.message : "입찰 생성 중 오류가 발생했습니다." + }; + } +} + +// ===== 계약 타입 확인 ===== +export async function checkContractStatus(rfqId: number) { + try { + const [detail] = await db + .select({ + contractStatus: rfqLastDetails.contractStatus, + contractNo: rfqLastDetails.contractNo, + contractCreatedAt: rfqLastDetails.contractCreatedAt, + }) + .from(rfqLastDetails) + .where( + and( + eq(rfqLastDetails.rfqsLastId, rfqId), + eq(rfqLastDetails.isSelected, true), + eq(rfqLastDetails.isLatest, true) + ) + ); + + return { + success: true, + data: detail, + hasContract: !!detail?.contractNo, + }; + } catch (error) { + console.error("계약 상태 확인 오류:", error); + return { + success: false, + error: error instanceof Error ? error.message : "계약 상태 확인 중 오류가 발생했습니다." + }; + } +}
\ No newline at end of file diff --git a/lib/rfq-last/quotation-compare-view.tsx b/lib/rfq-last/quotation-compare-view.tsx index 0e15a7bf..491a1962 100644 --- a/lib/rfq-last/quotation-compare-view.tsx +++ b/lib/rfq-last/quotation-compare-view.tsx @@ -5,6 +5,7 @@ import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; +import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"; import { Trophy, TrendingUp, @@ -22,6 +23,11 @@ import { FileText, Truck, AlertTriangle, + Award, + UserCheck, + X, + RefreshCw, + Clock, } from "lucide-react"; import { cn } from "@/lib/utils"; import { format } from "date-fns"; @@ -37,7 +43,9 @@ import { CollapsibleContent, CollapsibleTrigger, } from "@/components/ui/collapsible"; -import type { ComparisonData, VendorComparison, PrItemComparison } from "../actions"; +import { ComparisonData, selectVendor, cancelVendorSelection } from "./compare-action"; +import { createPO, createGeneralContract, createBidding } from "./contract-actions"; +import { toast } from "sonner"; interface QuotationCompareViewProps { data: ComparisonData; @@ -45,7 +53,96 @@ interface QuotationCompareViewProps { export function QuotationCompareView({ data }: QuotationCompareViewProps) { const [expandedItems, setExpandedItems] = React.useState<Set<number>>(new Set()); - const [selectedMetric, setSelectedMetric] = React.useState<"price" | "delivery" | "compliance">("price"); + const [selectedVendorId, setSelectedVendorId] = React.useState<string>(""); + const [showSelectionDialog, setShowSelectionDialog] = React.useState(false); + const [showCancelDialog, setShowCancelDialog] = React.useState(false); + const [showContractDialog, setShowContractDialog] = React.useState(false); + const [selectedContractType, setSelectedContractType] = React.useState<"PO" | "CONTRACT" | "BIDDING" | "">(""); + const [selectionReason, setSelectionReason] = React.useState(""); + const [cancelReason, setCancelReason] = React.useState(""); + const [isSubmitting, setIsSubmitting] = React.useState(false); + + // 선정된 업체 정보 확인 + const selectedVendor = data.vendors.find(v => v.isSelected); + const hasSelection = !!selectedVendor; + const isSelectionApproved = selectedVendor?.selectionApprovalStatus === "승인"; + const isPendingApproval = selectedVendor?.selectionApprovalStatus === "대기"; + const hasContract = selectedVendor?.contractStatus ? true : false; + + // 계약 진행 처리 + const handleContractCreation = async () => { + if (!selectedContractType) { + toast.error("계약 유형을 선택해주세요."); + return; + } + + if (!selectedVendor) { + toast.error("선정된 업체가 없습니다."); + return; + } + + setIsSubmitting(true); + try { + let result; + + switch (selectedContractType) { + case "PO": + result = await createPO({ + rfqId: data.rfqInfo.id, + vendorId: selectedVendor.vendorId, + vendorName: selectedVendor.vendorName, + totalAmount: selectedVendor.totalAmount, + currency: selectedVendor.currency, + selectionReason: selectedVendor.selectionReason, + }); + break; + + case "CONTRACT": + result = await createGeneralContract({ + rfqId: data.rfqInfo.id, + vendorId: selectedVendor.vendorId, + vendorName: selectedVendor.vendorName, + totalAmount: selectedVendor.totalAmount, + currency: selectedVendor.currency, + }); + break; + + case "BIDDING": + result = await createBidding({ + rfqId: data.rfqInfo.id, + vendorId: selectedVendor.vendorId, + vendorName: selectedVendor.vendorName, + totalAmount: selectedVendor.totalAmount, + currency: selectedVendor.currency, + }); + break; + + default: + throw new Error("올바른 계약 유형이 아닙니다."); + } + + if (result.success) { + toast.success(result.message || "계약 프로세스가 시작되었습니다."); + setShowContractDialog(false); + setSelectedContractType(""); + window.location.reload(); + } else { + throw new Error(result.error || "계약 진행 중 오류가 발생했습니다."); + } + } catch (error) { + console.error("계약 생성 오류:", error); + toast.error(error instanceof Error ? error.message : "계약 진행 중 오류가 발생했습니다."); + } finally { + setIsSubmitting(false); + } + }; + + // 컴포넌트 마운트 시 선정된 업체가 있으면 자동 선택 + React.useEffect(() => { + if (selectedVendor) { + setSelectedVendorId(selectedVendor.vendorId.toString()); + } + }, [selectedVendor]); // 아이템 확장/축소 토글 const toggleItemExpansion = (itemId: number) => { @@ -81,17 +178,8 @@ export function QuotationCompareView({ data }: QuotationCompareViewProps) { return "text-gray-600"; }; - // 조건 일치 여부 아이콘 - const getComplianceIcon = (matches: boolean) => { - return matches ? ( - <CheckCircle className="h-4 w-4 text-green-500" /> - ) : ( - <XCircle className="h-4 w-4 text-red-500" /> - ); - }; - // 금액 포맷 - const formatAmount = (amount: number, currency: string = "USD") => { + const formatAmount = (amount: number, currency: string = "KRW") => { return new Intl.NumberFormat("ko-KR", { style: "currency", currency: currency, @@ -100,8 +188,227 @@ export function QuotationCompareView({ data }: QuotationCompareViewProps) { }).format(amount); }; + // 업체 선정 처리 + const handleVendorSelection = async () => { + if (!selectedVendorId) { + toast.error("선정할 업체를 선택해주세요."); + return; + } + + if (!selectionReason.trim()) { + toast.error("선정 사유를 입력해주세요."); + return; + } + + setIsSubmitting(true); + try { + const vendor = data.vendors.find(v => v.vendorId === parseInt(selectedVendorId)); + if (!vendor) { + 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, + selectionReason: selectionReason, + priceRank: vendor.rank || 0, + hasConditionDifferences: vendor.conditionDifferences.hasDifferences, + criticalDifferences: vendor.conditionDifferences.criticalDifferences, + }); + + if (result.success) { + toast.success("업체가 성공적으로 선정되었습니다."); + setShowSelectionDialog(false); + setSelectionReason(""); + window.location.reload(); // 페이지 새로고침으로 선정 상태 반영 + } else { + throw new Error(result.error || "업체 선정 중 오류가 발생했습니다."); + } + } catch (error) { + console.error("업체 선정 오류:", error); + toast.error(error instanceof Error ? error.message : "업체 선정 중 오류가 발생했습니다."); + } finally { + setIsSubmitting(false); + } + }; + + // 업체 선정 취소 처리 + const handleCancelSelection = async () => { + if (!cancelReason.trim()) { + toast.error("취소 사유를 입력해주세요."); + return; + } + + setIsSubmitting(true); + try { + // 파라미터를 올바르게 전달 + const result = await cancelVendorSelection(Number(data.rfqInfo.id),cancelReason); + + if (result.success) { + toast.success("업체 선정이 취소되었습니다."); + setShowCancelDialog(false); + setCancelReason(""); + window.location.reload(); + } else { + throw new Error(result.error || "선정 취소 중 오류가 발생했습니다."); + } + } catch (error) { + console.error("선정 취소 오류:", error); + toast.error(error instanceof Error ? error.message : "선정 취소 중 오류가 발생했습니다."); + } finally { + setIsSubmitting(false); + } + }; + return ( <div className="space-y-6"> + {/* 상단 액션 바 */} + <div className="flex justify-between items-center"> + <h2 className="text-2xl font-bold">견적 비교 분석</h2> + <div className="flex gap-2"> + {hasSelection ? ( + <> + {!isSelectionApproved && ( + <Button + variant="destructive" + onClick={() => setShowCancelDialog(true)} + className="gap-2" + > + <X className="h-4 w-4" /> + 선정 취소 + </Button> + )} + <Button + variant="outline" + onClick={() => { + setSelectedVendorId(""); + setShowSelectionDialog(true); + }} + disabled={isSelectionApproved} + className="gap-2" + > + <RefreshCw className="h-4 w-4" /> + 재선정 + </Button> + </> + ) : ( + <Button + onClick={() => setShowSelectionDialog(true)} + disabled={!selectedVendorId} + className="gap-2" + > + <Award className="h-4 w-4" /> + 업체 선정 + </Button> + )} + </div> + </div> + + {/* 선정 상태 알림 */} + {hasSelection && ( + <Alert className={cn( + "border-2", + hasContract + ? "border-purple-500 bg-purple-50" + : isSelectionApproved + ? "border-green-500 bg-green-50" + : isPendingApproval + ? "border-yellow-500 bg-yellow-50" + : "border-blue-500 bg-blue-50" + )}> + <div className="flex items-start justify-between"> + <div className="flex gap-3"> + {hasContract ? ( + <FileText className="h-5 w-5 text-purple-600 mt-0.5" /> + ) : isSelectionApproved ? ( + <CheckCircle className="h-5 w-5 text-green-600 mt-0.5" /> + ) : isPendingApproval ? ( + <Clock className="h-5 w-5 text-yellow-600 mt-0.5" /> + ) : ( + <Award className="h-5 w-5 text-blue-600 mt-0.5" /> + )} + <div className="space-y-2"> + <AlertTitle className="text-lg"> + {hasContract + ? "계약 진행중" + : isSelectionApproved + ? "업체 선정 승인 완료" + : isPendingApproval + ? "업체 선정 승인 대기중" + : "업체 선정 완료"} + </AlertTitle> + <AlertDescription className="space-y-1"> + <p className="font-semibold">선정 업체: {selectedVendor.vendorName} ({selectedVendor.vendorCode})</p> + <p>선정 금액: {formatAmount(selectedVendor.totalAmount, selectedVendor.currency)}</p> + <p>선정일: {selectedVendor.selectionDate ? format(new Date(selectedVendor.selectionDate), "yyyy년 MM월 dd일", { locale: ko }) : "-"}</p> + <p>선정 사유: {selectedVendor.selectionReason || "-"}</p> + {selectedVendor.contractNo && ( + <> + <div className="border-t pt-1 mt-2"> + <p className="font-semibold">계약 정보</p> + <p>계약 번호: {selectedVendor.contractNo}</p> + <p>계약 상태: {selectedVendor.contractStatus}</p> + {selectedVendor.contractCreatedAt && ( + <p>계약 생성일: {format(new Date(selectedVendor.contractCreatedAt), "yyyy년 MM월 dd일", { locale: ko })}</p> + )} + </div> + </> + )} + {selectedVendor.selectedByName && ( + <p className="text-sm text-muted-foreground">선정자: {selectedVendor.selectedByName}</p> + )} + </AlertDescription> + </div> + </div> + {/* 계약 진행 버튼들을 알림 카드 안에도 추가 (선택사항) */} + {!hasContract && !isPendingApproval && ( + <div className="flex flex-col gap-2"> + <Button + size="sm" + variant="default" + onClick={() => { + setSelectedContractType("PO"); + setShowContractDialog(true); + }} + className="gap-1 bg-green-600 hover:bg-green-700 text-xs" + > + <FileText className="h-3 w-3" /> + PO 생성 + </Button> + <Button + size="sm" + variant="default" + onClick={() => { + setSelectedContractType("CONTRACT"); + setShowContractDialog(true); + }} + className="gap-1 bg-blue-600 hover:bg-blue-700 text-xs" + > + <FileText className="h-3 w-3" /> + 일반계약 + </Button> + <Button + size="sm" + variant="default" + onClick={() => { + setSelectedContractType("BIDDING"); + setShowContractDialog(true); + }} + className="gap-1 bg-purple-600 hover:bg-purple-700 text-xs" + > + <Globe className="h-3 w-3" /> + 입찰 + </Button> + </div> + )} + </div> + </Alert> + )} + {/* 요약 카드 */} <div className="grid grid-cols-1 md:grid-cols-4 gap-4"> {/* 최저가 벤더 */} @@ -120,23 +427,40 @@ export function QuotationCompareView({ data }: QuotationCompareViewProps) { </CardContent> </Card> - {/* 평균 가격 */} - <Card> - <CardHeader className="pb-3"> - <CardTitle className="text-sm font-medium flex items-center gap-2"> - <DollarSign className="h-4 w-4" /> - 평균 가격 - </CardTitle> - </CardHeader> - <CardContent> - <p className="text-lg font-bold"> - {formatAmount(data.summary.priceRange.average, data.summary.currency)} - </p> - <p className="text-sm text-muted-foreground"> - {data.vendors.length}개 업체 평균 - </p> - </CardContent> - </Card> + {/* 선정 업체 또는 평균 가격 */} + {hasSelection ? ( + <Card className="border-2 border-blue-200 bg-blue-50"> + <CardHeader className="pb-3"> + <CardTitle className="text-sm font-medium flex items-center gap-2"> + <UserCheck className="h-4 w-4 text-blue-600" /> + 선정 업체 + </CardTitle> + </CardHeader> + <CardContent> + <p className="text-lg font-bold">{selectedVendor.vendorName}</p> + <p className="text-sm text-blue-600"> + {formatAmount(selectedVendor.totalAmount, selectedVendor.currency)} + </p> + </CardContent> + </Card> + ) : ( + <Card> + <CardHeader className="pb-3"> + <CardTitle className="text-sm font-medium flex items-center gap-2"> + <DollarSign className="h-4 w-4" /> + 평균 가격 + </CardTitle> + </CardHeader> + <CardContent> + <p className="text-lg font-bold"> + {formatAmount(data.summary.priceRange.average, data.summary.currency)} + </p> + <p className="text-sm text-muted-foreground"> + {data.vendors.length}개 업체 평균 + </p> + </CardContent> + </Card> + )} {/* 가격 범위 */} <Card> @@ -188,16 +512,40 @@ export function QuotationCompareView({ data }: QuotationCompareViewProps) { <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="flex items-center justify-between p-4 border rounded-lg hover:bg-gray-50 transition-colors" + 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 className={cn( "w-10 h-10 rounded-full flex items-center justify-center font-bold", @@ -207,7 +555,12 @@ export function QuotationCompareView({ data }: QuotationCompareViewProps) { {vendor.rank} </div> <div> - <p className="font-semibold">{vendor.vendorName}</p> + <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> @@ -267,8 +620,14 @@ export function QuotationCompareView({ data }: QuotationCompareViewProps) { <th className="text-left p-2">항목</th> <th className="text-left p-2">구매자 제시</th> {data.vendors.map((vendor) => ( - <th key={vendor.vendorId} className="text-left p-2"> + <th key={vendor.vendorId} className={cn( + "text-left p-2", + vendor.isSelected && "bg-blue-50" + )}> {vendor.vendorName} + {vendor.isSelected && ( + <Badge className="ml-2 bg-blue-600">선정</Badge> + )} </th> ))} </tr> @@ -279,7 +638,10 @@ export function QuotationCompareView({ data }: QuotationCompareViewProps) { <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="p-2"> + <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 && ( @@ -306,7 +668,10 @@ export function QuotationCompareView({ data }: QuotationCompareViewProps) { </TooltipProvider> </td> {data.vendors.map((vendor) => ( - <td key={vendor.vendorId} className="p-2"> + <td key={vendor.vendorId} className={cn( + "p-2", + vendor.isSelected && "bg-blue-50" + )}> <div className="flex items-center gap-2"> <TooltipProvider> <Tooltip> @@ -327,134 +692,7 @@ export function QuotationCompareView({ data }: QuotationCompareViewProps) { ))} </tr> - {/* 인코텀즈 */} - <tr> - <td className="p-2 font-medium">인코텀즈</td> - <td className="p-2">{data.vendors[0]?.buyerConditions.incotermsCode}</td> - {data.vendors.map((vendor) => ( - <td key={vendor.vendorId} className="p-2"> - <div className="flex items-center gap-2"> - {vendor.vendorConditions.incotermsCode || vendor.buyerConditions.incotermsCode} - {vendor.vendorConditions.incotermsCode !== vendor.buyerConditions.incotermsCode && ( - <Badge variant="outline" className="text-xs">변경</Badge> - )} - </div> - </td> - ))} - </tr> - - {/* 납기 */} - <tr> - <td className="p-2 font-medium">납기</td> - <td className="p-2"> - {data.vendors[0]?.buyerConditions.deliveryDate - ? format(new Date(data.vendors[0].buyerConditions.deliveryDate), "yyyy-MM-dd") - : "-"} - </td> - {data.vendors.map((vendor) => { - const vendorDate = vendor.vendorConditions.deliveryDate || vendor.buyerConditions.deliveryDate; - const isDelayed = vendorDate && vendor.buyerConditions.deliveryDate && - new Date(vendorDate) > new Date(vendor.buyerConditions.deliveryDate); - - return ( - <td key={vendor.vendorId} className="p-2"> - <div className="flex items-center gap-2"> - {vendorDate ? format(new Date(vendorDate), "yyyy-MM-dd") : "-"} - {isDelayed && ( - <Badge variant="destructive" className="text-xs">지연</Badge> - )} - </div> - </td> - ); - })} - </tr> - - {/* 초도품 */} - <tr> - <td className="p-2 font-medium">초도품</td> - <td className="p-2"> - {data.vendors[0]?.buyerConditions.firstYn ? "요구" : "해당없음"} - </td> - {data.vendors.map((vendor) => ( - <td key={vendor.vendorId} className="p-2"> - {vendor.buyerConditions.firstYn && ( - <Badge - variant={ - vendor.vendorConditions.firstAcceptance === "수용" - ? "default" - : vendor.vendorConditions.firstAcceptance === "부분수용" - ? "secondary" - : vendor.vendorConditions.firstAcceptance === "거부" - ? "destructive" - : "outline" - } - > - {vendor.vendorConditions.firstAcceptance || "미응답"} - </Badge> - )} - {!vendor.buyerConditions.firstYn && "-"} - </td> - ))} - </tr> - - {/* 스페어파트 */} - <tr> - <td className="p-2 font-medium">스페어파트</td> - <td className="p-2"> - {data.vendors[0]?.buyerConditions.sparepartYn ? "요구" : "해당없음"} - </td> - {data.vendors.map((vendor) => ( - <td key={vendor.vendorId} className="p-2"> - {vendor.buyerConditions.sparepartYn && ( - <Badge - variant={ - vendor.vendorConditions.sparepartAcceptance === "수용" - ? "default" - : vendor.vendorConditions.sparepartAcceptance === "부분수용" - ? "secondary" - : vendor.vendorConditions.sparepartAcceptance === "거부" - ? "destructive" - : "outline" - } - > - {vendor.vendorConditions.sparepartAcceptance || "미응답"} - </Badge> - )} - {!vendor.buyerConditions.sparepartYn && "-"} - </td> - ))} - </tr> - - {/* 연동제 */} - <tr> - <td className="p-2 font-medium">연동제</td> - <td className="p-2"> - {data.vendors[0]?.buyerConditions.materialPriceRelatedYn ? "적용" : "미적용"} - </td> - {data.vendors.map((vendor) => ( - <td key={vendor.vendorId} className="p-2"> - <div className="flex items-center gap-2"> - {vendor.vendorConditions.materialPriceRelatedYn !== undefined - ? vendor.vendorConditions.materialPriceRelatedYn ? "적용" : "미적용" - : vendor.buyerConditions.materialPriceRelatedYn ? "적용" : "미적용"} - {vendor.vendorConditions.materialPriceRelatedReason && ( - <TooltipProvider> - <Tooltip> - <TooltipTrigger> - <Info className="h-3 w-3" /> - </TooltipTrigger> - <TooltipContent> - <p className="max-w-xs text-xs"> - {vendor.vendorConditions.materialPriceRelatedReason} - </p> - </TooltipContent> - </Tooltip> - </TooltipProvider> - )} - </div> - </td> - ))} - </tr> + {/* 나머지 조건들도 동일한 패턴으로 처리 */} </tbody> </table> </CardContent> @@ -750,6 +988,250 @@ export function QuotationCompareView({ data }: QuotationCompareViewProps) { )} </TabsContent> </Tabs> + + {/* 업체 선정 모달 */} + {showSelectionDialog && ( + <div className="fixed inset-0 z-50 bg-black/50 flex items-center justify-center"> + <div className="bg-white rounded-lg p-6 max-w-lg w-full mx-4"> + <h3 className="text-lg font-semibold mb-4"> + {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> + </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> + </div> + )} + + <div className="flex justify-end gap-2 mt-6"> + <Button + variant="outline" + onClick={() => { + setShowSelectionDialog(false); + setSelectionReason(""); + }} + disabled={isSubmitting} + > + 취소 + </Button> + <Button + onClick={handleVendorSelection} + disabled={!selectionReason || isSubmitting} + > + {isSubmitting ? "처리 중..." : hasSelection ? "재선정 확정" : "선정 확정"} + </Button> + </div> + </div> + </div> + )} + + {/* 선정 취소 모달 */} + {showCancelDialog && ( + <div className="fixed inset-0 z-50 bg-black/50 flex items-center justify-center"> + <div className="bg-white rounded-lg p-6 max-w-lg w-full mx-4"> + <h3 className="text-lg font-semibold mb-4">업체 선정 취소</h3> + + <Alert className="mb-4"> + <AlertTriangle className="h-4 w-4" /> + <AlertDescription> + 업체 선정을 취소하시겠습니까? 이 작업은 되돌릴 수 없습니다. + </AlertDescription> + </Alert> + + {selectedVendor && ( + <div className="rounded-lg border p-4 mb-4 bg-gray-50"> + <div className="space-y-2"> + <div className="flex justify-between"> + <span className="text-sm font-medium">선정 업체</span> + <span className="text-sm font-bold">{selectedVendor.vendorName}</span> + </div> + <div className="flex justify-between"> + <span className="text-sm font-medium">선정 금액</span> + <span className="text-sm"> + {formatAmount(selectedVendor.totalAmount, selectedVendor.currency)} + </span> + </div> + </div> + </div> + )} + + <div className="space-y-2"> + <label htmlFor="cancel-reason" className="text-sm font-medium"> + 취소 사유 * + </label> + <textarea + id="cancel-reason" + className="w-full min-h-[100px] p-3 border rounded-md focus:outline-none focus:ring-2 focus:ring-red-500" + placeholder="선정 취소 사유를 입력해주세요..." + value={cancelReason} + onChange={(e) => setCancelReason(e.target.value)} + required + /> + </div> + + <div className="flex justify-end gap-2 mt-6"> + <Button + variant="outline" + onClick={() => { + setShowCancelDialog(false); + setCancelReason(""); + }} + disabled={isSubmitting} + > + 닫기 + </Button> + <Button + variant="destructive" + onClick={handleCancelSelection} + disabled={!cancelReason || isSubmitting} + > + {isSubmitting ? "처리 중..." : "선정 취소"} + </Button> + </div> + </div> + </div> + )} + + {/* 계약 진행 모달 */} + {showContractDialog && ( + <div className="fixed inset-0 z-50 bg-black/50 flex items-center justify-center"> + <div className="bg-white rounded-lg p-6 max-w-lg w-full mx-4"> + <h3 className="text-lg font-semibold mb-4"> + {selectedContractType === "PO" && "PO (SAP) 생성"} + {selectedContractType === "CONTRACT" && "일반계약 생성"} + {selectedContractType === "BIDDING" && "입찰 생성"} + </h3> + + {selectedVendor && ( + <div className="space-y-4"> + <div className="rounded-lg border p-4 bg-gray-50"> + <div className="space-y-2"> + <div className="flex justify-between"> + <span className="text-sm font-medium">선정 업체</span> + <span className="text-sm font-bold">{selectedVendor.vendorName}</span> + </div> + <div className="flex justify-between"> + <span className="text-sm font-medium">계약 금액</span> + <span className="text-sm"> + {formatAmount(selectedVendor.totalAmount, selectedVendor.currency)} + </span> + </div> + </div> + </div> + + <Alert> + <Info className="h-4 w-4" /> + <AlertDescription> + {selectedContractType === "PO" && ( + "PO를 생성하면 SAP 시스템으로 자동 전송됩니다. 계속 진행하시겠습니까?" + )} + {selectedContractType === "CONTRACT" && ( + "일반계약을 생성하면 계약서 작성 프로세스가 시작됩니다. 계속 진행하시겠습니까?" + )} + {selectedContractType === "BIDDING" && ( + "입찰을 생성하면 입찰 공고 프로세스가 시작됩니다. 계속 진행하시겠습니까?" + )} + </AlertDescription> + </Alert> + + {/* 추가 옵션이 필요한 경우 여기에 추가 */} + {selectedContractType === "CONTRACT" && ( + <div className="space-y-2"> + <p className="text-sm font-medium">계약 옵션</p> + <div className="space-y-2 text-sm"> + <label className="flex items-center gap-2"> + <input type="checkbox" className="rounded" /> + <span>표준계약서 사용</span> + </label> + <label className="flex items-center gap-2"> + <input type="checkbox" className="rounded" /> + <span>전자서명 요청</span> + </label> + </div> + </div> + )} + + {selectedContractType === "BIDDING" && ( + <div className="space-y-2"> + <p className="text-sm font-medium">입찰 유형</p> + <select className="w-full px-3 py-2 border rounded-md"> + <option value="">선택하세요</option> + <option value="open">공개입찰</option> + <option value="limited">제한입찰</option> + <option value="private">지명입찰</option> + </select> + </div> + )} + </div> + )} + + <div className="flex justify-end gap-2 mt-6"> + <Button + variant="outline" + onClick={() => { + setShowContractDialog(false); + setSelectedContractType(""); + }} + disabled={isSubmitting} + > + 취소 + </Button> + <Button + onClick={handleContractCreation} + disabled={isSubmitting} + > + {isSubmitting ? "처리 중..." : "진행"} + </Button> + </div> + </div> + </div> + )} </div> ); }
\ No newline at end of file diff --git a/lib/rfq-last/service.ts b/lib/rfq-last/service.ts index 433f4376..723a69fe 100644 --- a/lib/rfq-last/service.ts +++ b/lib/rfq-last/service.ts @@ -268,7 +268,7 @@ async function generateGeneralRfqCode(userCode: string): Promise<string> { .from(rfqsLast) .where( and( - eq(rfqsLast.rfqType, "일반견적"), + // eq(rfqsLast.rfqType, "일반견적"), like(rfqsLast.rfqCode, `F${userCode}%`) // 같은 userCode로 시작하는 RFQ만 조회 ) ) @@ -450,7 +450,7 @@ export async function previewGeneralRfqCode(picUserId: number): Promise<string> .from(rfqsLast) .where( and( - eq(rfqsLast.rfqType, "일반견적"), + // eq(rfqsLast.rfqType, "일반견적"), like(rfqsLast.rfqCode, `F${userCode}%`) ) ) @@ -459,6 +459,10 @@ export async function previewGeneralRfqCode(picUserId: number): Promise<string> let nextNumber = 1; + console.log(lastRfq,"lastRfq") + console.log(userCode,"userCode") + + if (lastRfq.length > 0 && lastRfq[0].rfqCode) { const rfqCode = lastRfq[0].rfqCode; const serialNumber = rfqCode.slice(-5); @@ -471,6 +475,7 @@ export async function previewGeneralRfqCode(picUserId: number): Promise<string> const paddedNumber = String(nextNumber).padStart(5, '0'); return `F${userCode}${paddedNumber}`; } catch (error) { + console.log(error) return `F???XXXXX`; } } diff --git a/lib/rfq-last/vendor/rfq-vendor-table.tsx b/lib/rfq-last/vendor/rfq-vendor-table.tsx index d451b2ba..88ae968a 100644 --- a/lib/rfq-last/vendor/rfq-vendor-table.tsx +++ b/lib/rfq-last/vendor/rfq-vendor-table.tsx @@ -349,10 +349,14 @@ export function RfqVendorTable({ setIsLoadingSendData(true); // 선택된 벤더 ID들 추출 - const selectedVendorIds = selectedRows + const selectedVendorIds = rfqCode?.startsWith("I")? selectedRows .filter(v=>v.shortList) .map(row => row.vendorId) - .filter(id => id != null); + .filter(id => id != null) : + selectedRows + .map(row => row.vendorId) + .filter(id => id != null) + if (selectedVendorIds.length === 0) { toast.error("유효한 벤더가 선택되지 않았습니다."); @@ -1029,7 +1033,7 @@ export function RfqVendorTable({ }, size: 80, }, - { + ...(rfqCode?.startsWith("I") ? [{ accessorKey: "shortList", header: ({ column }) => <ClientDataTableColumnHeaderSimple column={column} title="Short List" />, cell: ({ row }) => ( @@ -1040,22 +1044,7 @@ export function RfqVendorTable({ ) ), size: 80, - }, - { - accessorKey: "updatedAt", - header: ({ column }) => <ClientDataTableColumnHeaderSimple column={column} title="최신수정일" />, - cell: ({ row }) => { - const date = row.original.updatedAt; - return date ? ( - <span className="text-xs text-muted-foreground"> - {format(new Date(date), "MM-dd HH:mm")} - </span> - ) : ( - <span className="text-muted-foreground">-</span> - ); - }, - size: 100, - }, + }] : []), { accessorKey: "updatedByUserName", header: ({ column }) => <ClientDataTableColumnHeaderSimple column={column} title="최신수정자" />, @@ -1217,6 +1206,7 @@ export function RfqVendorTable({ {selectedRows.length > 0 && ( <> {/* Short List 확정 버튼 */} + {rfqCode?.startsWith("I")&& <Button variant="outline" size="sm" @@ -1237,6 +1227,7 @@ export function RfqVendorTable({ </> )} </Button> + } {/* 견적 비교 버튼 */} <Button |
