From ba8cd44a0ed2c613a5f2cee06bfc9bd0f61f21c7 Mon Sep 17 00:00:00 2001 From: dujinkim Date: Fri, 7 Nov 2025 08:39:04 +0000 Subject: (최겸) 입찰/견적 수정사항 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/bidding/actions.ts | 1065 ++--- lib/bidding/bidding-notice-editor.tsx | 99 +- lib/bidding/bidding-notice-template-manager.tsx | 63 + lib/bidding/detail/bidding-actions.ts | 227 ++ lib/bidding/detail/service.ts | 160 +- .../detail/table/bidding-detail-content.tsx | 201 +- .../detail/table/bidding-detail-vendor-columns.tsx | 23 +- .../table/bidding-detail-vendor-create-dialog.tsx | 328 -- .../bidding-detail-vendor-toolbar-actions.tsx | 8 +- .../detail/table/bidding-invitation-dialog.tsx | 718 +++- lib/bidding/failure/biddings-failure-columns.tsx | 320 ++ lib/bidding/failure/biddings-failure-table.tsx | 223 ++ lib/bidding/list/bidding-detail-dialogs.tsx | 554 +-- lib/bidding/list/bidding-pr-documents-dialog.tsx | 405 ++ lib/bidding/list/biddings-table-columns.tsx | 649 ++- .../list/biddings-table-toolbar-actions.tsx | 64 +- lib/bidding/list/biddings-table.tsx | 30 +- lib/bidding/list/create-bidding-dialog.tsx | 4230 ++++++++++---------- lib/bidding/pre-quote/service.ts | 3145 ++++++++------- .../table/bidding-pre-quote-attachments-dialog.tsx | 224 -- .../pre-quote/table/bidding-pre-quote-content.tsx | 51 - .../table/bidding-pre-quote-invitation-dialog.tsx | 770 ---- .../bidding-pre-quote-item-details-dialog.tsx | 125 - .../table/bidding-pre-quote-selection-dialog.tsx | 157 - .../table/bidding-pre-quote-vendor-columns.tsx | 398 -- .../bidding-pre-quote-vendor-create-dialog.tsx | 311 -- .../table/bidding-pre-quote-vendor-edit-dialog.tsx | 200 - .../table/bidding-pre-quote-vendor-table.tsx | 257 -- .../bidding-pre-quote-vendor-toolbar-actions.tsx | 130 - lib/bidding/receive/biddings-receive-columns.tsx | 360 ++ lib/bidding/receive/biddings-receive-table.tsx | 211 + .../selection/biddings-selection-columns.tsx | 289 ++ lib/bidding/selection/biddings-selection-table.tsx | 218 + lib/bidding/service.ts | 2953 ++++++++++++-- lib/bidding/validation.ts | 170 +- .../vendor/components/pr-items-pricing-table.tsx | 27 +- lib/bidding/vendor/partners-bidding-detail.tsx | 904 ++++- .../vendor/partners-bidding-list-columns.tsx | 104 +- lib/bidding/vendor/partners-bidding-pre-quote.tsx | 1413 ------- .../general-contract-approval-request-dialog.tsx | 141 +- .../detail/general-contract-basic-info.tsx | 488 ++- .../general-contract-communication-channel.tsx | 362 -- .../detail/general-contract-detail.tsx | 81 +- .../detail/general-contract-documents.tsx | 11 +- .../detail/general-contract-field-service-rate.tsx | 288 -- .../detail/general-contract-info-header.tsx | 5 +- .../detail/general-contract-items-table.tsx | 292 +- .../detail/general-contract-location.tsx | 480 --- .../detail/general-contract-offset-details.tsx | 314 -- .../detail/general-contract-review-comments.tsx | 194 + .../general-contract-review-request-dialog.tsx | 891 +++++ .../detail/general-contract-storage-info.tsx | 249 ++ .../general-contract-subcontract-checklist.tsx | 47 +- .../detail/general-contract-yard-entry-info.tsx | 232 ++ .../main/create-general-contract-dialog.tsx | 156 +- .../main/general-contract-update-sheet.tsx | 53 +- .../main/general-contracts-table-columns.tsx | 34 +- .../main/general-contracts-table.tsx | 5 +- lib/general-contracts/service.ts | 1102 ++++- lib/general-contracts/types.ts | 8 +- .../general-contract-approval-request-dialog.tsx | 1312 ++++++ .../detail/general-contract-basic-info.tsx | 1250 ++++++ .../general-contract-communication-channel.tsx | 362 ++ .../detail/general-contract-detail.tsx | 186 + .../detail/general-contract-documents.tsx | 383 ++ .../detail/general-contract-field-service-rate.tsx | 288 ++ .../detail/general-contract-info-header.tsx | 211 + .../detail/general-contract-items-table.tsx | 602 +++ .../detail/general-contract-location.tsx | 480 +++ .../detail/general-contract-offset-details.tsx | 314 ++ .../general-contract-subcontract-checklist.tsx | 610 +++ .../main/create-general-contract-dialog.tsx | 413 ++ .../main/general-contract-update-sheet.tsx | 401 ++ .../main/general-contracts-table-columns.tsx | 571 +++ .../general-contracts-table-toolbar-actions.tsx | 124 + .../main/general-contracts-table.tsx | 217 + lib/general-contracts_old/service.ts | 1933 +++++++++ lib/general-contracts_old/types.ts | 125 + lib/general-contracts_old/validation.ts | 82 + 79 files changed, 25029 insertions(+), 12052 deletions(-) create mode 100644 lib/bidding/bidding-notice-template-manager.tsx create mode 100644 lib/bidding/detail/bidding-actions.ts delete mode 100644 lib/bidding/detail/table/bidding-detail-vendor-create-dialog.tsx create mode 100644 lib/bidding/failure/biddings-failure-columns.tsx create mode 100644 lib/bidding/failure/biddings-failure-table.tsx create mode 100644 lib/bidding/list/bidding-pr-documents-dialog.tsx delete mode 100644 lib/bidding/pre-quote/table/bidding-pre-quote-attachments-dialog.tsx delete mode 100644 lib/bidding/pre-quote/table/bidding-pre-quote-content.tsx delete mode 100644 lib/bidding/pre-quote/table/bidding-pre-quote-invitation-dialog.tsx delete mode 100644 lib/bidding/pre-quote/table/bidding-pre-quote-item-details-dialog.tsx delete mode 100644 lib/bidding/pre-quote/table/bidding-pre-quote-selection-dialog.tsx delete mode 100644 lib/bidding/pre-quote/table/bidding-pre-quote-vendor-columns.tsx delete mode 100644 lib/bidding/pre-quote/table/bidding-pre-quote-vendor-create-dialog.tsx delete mode 100644 lib/bidding/pre-quote/table/bidding-pre-quote-vendor-edit-dialog.tsx delete mode 100644 lib/bidding/pre-quote/table/bidding-pre-quote-vendor-table.tsx delete mode 100644 lib/bidding/pre-quote/table/bidding-pre-quote-vendor-toolbar-actions.tsx create mode 100644 lib/bidding/receive/biddings-receive-columns.tsx create mode 100644 lib/bidding/receive/biddings-receive-table.tsx create mode 100644 lib/bidding/selection/biddings-selection-columns.tsx create mode 100644 lib/bidding/selection/biddings-selection-table.tsx delete mode 100644 lib/bidding/vendor/partners-bidding-pre-quote.tsx delete mode 100644 lib/general-contracts/detail/general-contract-communication-channel.tsx delete mode 100644 lib/general-contracts/detail/general-contract-field-service-rate.tsx delete mode 100644 lib/general-contracts/detail/general-contract-location.tsx delete mode 100644 lib/general-contracts/detail/general-contract-offset-details.tsx create mode 100644 lib/general-contracts/detail/general-contract-review-comments.tsx create mode 100644 lib/general-contracts/detail/general-contract-review-request-dialog.tsx create mode 100644 lib/general-contracts/detail/general-contract-storage-info.tsx create mode 100644 lib/general-contracts/detail/general-contract-yard-entry-info.tsx create mode 100644 lib/general-contracts_old/detail/general-contract-approval-request-dialog.tsx create mode 100644 lib/general-contracts_old/detail/general-contract-basic-info.tsx create mode 100644 lib/general-contracts_old/detail/general-contract-communication-channel.tsx create mode 100644 lib/general-contracts_old/detail/general-contract-detail.tsx create mode 100644 lib/general-contracts_old/detail/general-contract-documents.tsx create mode 100644 lib/general-contracts_old/detail/general-contract-field-service-rate.tsx create mode 100644 lib/general-contracts_old/detail/general-contract-info-header.tsx create mode 100644 lib/general-contracts_old/detail/general-contract-items-table.tsx create mode 100644 lib/general-contracts_old/detail/general-contract-location.tsx create mode 100644 lib/general-contracts_old/detail/general-contract-offset-details.tsx create mode 100644 lib/general-contracts_old/detail/general-contract-subcontract-checklist.tsx create mode 100644 lib/general-contracts_old/main/create-general-contract-dialog.tsx create mode 100644 lib/general-contracts_old/main/general-contract-update-sheet.tsx create mode 100644 lib/general-contracts_old/main/general-contracts-table-columns.tsx create mode 100644 lib/general-contracts_old/main/general-contracts-table-toolbar-actions.tsx create mode 100644 lib/general-contracts_old/main/general-contracts-table.tsx create mode 100644 lib/general-contracts_old/service.ts create mode 100644 lib/general-contracts_old/types.ts create mode 100644 lib/general-contracts_old/validation.ts (limited to 'lib') diff --git a/lib/bidding/actions.ts b/lib/bidding/actions.ts index df9d0dad..b5736707 100644 --- a/lib/bidding/actions.ts +++ b/lib/bidding/actions.ts @@ -1,475 +1,590 @@ -"use server" - -import db from "@/db/db" -import { eq, and } from "drizzle-orm" -import { - biddings, - biddingCompanies, - prItemsForBidding, - companyPrItemBids, - vendors, - generalContracts, - generalContractItems, - biddingConditions -} from "@/db/schema" -import { createPurchaseOrder } from "@/lib/soap/ecc/send/create-po" -import { getCurrentSAPDate } from "@/lib/soap/utils" -import { generateContractNumber } from "@/lib/general-contracts/service" - -// TO Contract -export async function transmitToContract(biddingId: number, userId: number) { - try { - // 1. 입찰 정보 조회 (단순 쿼리) - const bidding = await db.select() - .from(biddings) - .where(eq(biddings.id, biddingId)) - .limit(1) - - if (!bidding || bidding.length === 0) { - throw new Error("입찰 정보를 찾을 수 없습니다.") - } - - const biddingData = bidding[0] - - // 2. 입찰 조건 정보 조회 - const biddingConditionData = await db.select() - .from(biddingConditions) - .where(eq(biddingConditions.biddingId, biddingId)) - .limit(1) - - const biddingCondition = biddingConditionData.length > 0 ? biddingConditionData[0] : null - - // 3. 낙찰된 업체들 조회 (biddingCompanies.id 포함) - const winnerCompaniesData = await db.select({ - id: biddingCompanies.id, - companyId: biddingCompanies.companyId, - finalQuoteAmount: biddingCompanies.finalQuoteAmount, - awardRatio: biddingCompanies.awardRatio, - vendorCode: vendors.vendorCode, - vendorName: vendors.vendorName - }) - .from(biddingCompanies) - .leftJoin(vendors, eq(biddingCompanies.companyId, vendors.id)) - .where( - and( - eq(biddingCompanies.biddingId, biddingId), - eq(biddingCompanies.isWinner, true) - ) - ) - - // 상태 검증 - if (biddingData.status !== 'vendor_selected') { - throw new Error("업체 선정이 완료되지 않은 입찰입니다.") - } - - // 낙찰된 업체 검증 - if (winnerCompaniesData.length === 0) { - throw new Error("낙찰된 업체가 없습니다.") - } - - // 일반/매각 입찰의 경우 비율 합계 100% 검증 - const contractType = biddingData.contractType - if (contractType === 'general' || contractType === 'sale') { - const totalRatio = winnerCompaniesData.reduce((sum, company) => - sum + (Number(company.awardRatio) || 0), 0) - - if (totalRatio !== 100) { - throw new Error(`일반/매각 입찰의 경우 비율 합계가 100%여야 합니다. 현재 합계: ${totalRatio}%`) - } - } - - for (const winnerCompany of winnerCompaniesData) { - // winnerCompany에서 직접 정보 사용 - const awardRatio = (Number(winnerCompany.awardRatio) || 100) / 100 - const biddingCompanyId = winnerCompany.id - - // 현재 winnerCompany의 입찰 데이터 조회 - const companyBids = await db.select({ - prItemId: companyPrItemBids.prItemId, - proposedDeliveryDate: companyPrItemBids.proposedDeliveryDate, - bidUnitPrice: companyPrItemBids.bidUnitPrice, - bidAmount: companyPrItemBids.bidAmount, - currency: companyPrItemBids.currency, - // PR 아이템 정보도 함께 조회 - itemNumber: prItemsForBidding.itemNumber, - itemInfo: prItemsForBidding.itemInfo, - materialDescription: prItemsForBidding.materialDescription, - quantity: prItemsForBidding.quantity, - quantityUnit: prItemsForBidding.quantityUnit, - }) - .from(companyPrItemBids) - .leftJoin(prItemsForBidding, eq(companyPrItemBids.prItemId, prItemsForBidding.id)) - .where(eq(companyPrItemBids.biddingCompanyId, biddingCompanyId)) - - // 발주비율에 따른 최종 계약금액 계산 - let totalContractAmount = 0 - if (companyBids.length > 0) { - for (const bid of companyBids) { - const originalQuantity = Number(bid.quantity) || 0 - const bidUnitPrice = Number(bid.bidUnitPrice) || 0 - const finalQuantity = originalQuantity * awardRatio - const finalAmount = finalQuantity * bidUnitPrice - totalContractAmount += finalAmount - } - } - - // 계약 번호 자동 생성 (실제 규칙에 맞게) - const contractNumber = await generateContractNumber(userId.toString(), biddingData.contractType) - console.log('Generated contractNumber:', contractNumber) - - // general-contract 생성 (발주비율 계산된 최종 금액 사용) - const contractResult = await db.insert(generalContracts).values({ - contractNumber, - revision: 0, - contractSourceType: 'bid', // 입찰에서 생성됨 - status: 'Draft', - category: biddingData.contractType || 'general', - name: biddingData.title, - vendorId: winnerCompany.companyId, - linkedBidNumber: biddingData.biddingNumber, - contractAmount: totalContractAmount ? totalContractAmount.toString() as any : null, // 발주비율 계산된 최종 금액 사용 - startDate: biddingData.contractStartDate || null, - endDate: biddingData.contractEndDate || null, - currency: biddingData.currency || 'KRW', - // 계약 조건 정보 추가 - paymentTerm: biddingCondition?.paymentTerms || null, - taxType: biddingCondition?.taxConditions || 'V0', - deliveryTerm: biddingCondition?.incoterms || 'FOB', - shippingLocation: biddingCondition?.shippingPort || null, - dischargeLocation: biddingCondition?.destinationPort || null, - registeredById: userId, - lastUpdatedById: userId, - }).returning({ id: generalContracts.id }) - console.log('contractResult', contractResult) - const contractId = contractResult[0].id - - // 현재 winnerCompany의 품목정보 생성 (발주비율 적용) - if (companyBids.length > 0) { - console.log(`Creating ${companyBids.length} contract items for winner company ${winnerCompany.companyId} with award ratio ${awardRatio}`) - for (const bid of companyBids) { - // 발주비율에 따른 최종 수량 계산 (중량 제외) - const originalQuantity = Number(bid.quantity) || 0 - const bidUnitPrice = Number(bid.bidUnitPrice) || 0 - - const finalQuantity = originalQuantity * awardRatio - const finalAmount = finalQuantity * bidUnitPrice - - await db.insert(generalContractItems).values({ - contractId: contractId, - itemCode: bid.itemNumber || '', - itemInfo: bid.itemInfo || '', - specification: bid.materialDescription || '', - quantity: finalQuantity || null, - quantityUnit: bid.quantityUnit || '', - totalWeight: null, // 중량 정보 제외 - weightUnit: '', // 중량 단위 제외 - contractDeliveryDate: bid.proposedDeliveryDate || null, - contractUnitPrice: bid.bidUnitPrice || null, - contractAmount: finalAmount ? finalAmount.toString() as any : null, - contractCurrency: bid.currency || biddingData.currency || 'KRW', - }) - } - console.log(`Created ${companyBids.length} contract items for winner company ${winnerCompany.companyId}`) - } else { - console.log(`No bid data found for winner company ${winnerCompany.companyId}`) - } - } - - return { success: true, message: `${winnerCompaniesData.length}개의 계약서가 생성되었습니다.` } - - } catch (error) { - console.error('TO Contract 실패:', error) - throw new Error(error instanceof Error ? error.message : '계약서 생성에 실패했습니다.') - } -} - -// TO PO -export async function transmitToPO(biddingId: number) { - try { - // 1. 입찰 정보 조회 - const biddingData = await db.select() - .from(biddings) - .where(eq(biddings.id, biddingId)) - .limit(1) - - if (!biddingData || biddingData.length === 0) { - throw new Error("입찰 정보를 찾을 수 없습니다.") - } - - const bidding = biddingData[0] - - if (bidding.status !== 'vendor_selected') { - throw new Error("업체 선정이 완료되지 않은 입찰입니다.") - } - - // 2. 입찰 조건 정보 조회 - const biddingConditionData = await db.select() - .from(biddingConditions) - .where(eq(biddingConditions.biddingId, biddingId)) - .limit(1) - - const biddingCondition = biddingConditionData.length > 0 ? biddingConditionData[0] : null - - // 3. 낙찰된 업체들 조회 (발주비율 포함) - const winnerCompaniesRaw = await db.select({ - id: biddingCompanies.id, - companyId: biddingCompanies.companyId, - finalQuoteAmount: biddingCompanies.finalQuoteAmount, - awardRatio: biddingCompanies.awardRatio, - vendorCode: vendors.vendorCode, - vendorName: vendors.vendorName - }) - .from(biddingCompanies) - .leftJoin(vendors, eq(biddingCompanies.companyId, vendors.id)) - .where( - and( - eq(biddingCompanies.biddingId, biddingId), - eq(biddingCompanies.isWinner, true) - ) - ) - - if (winnerCompaniesRaw.length === 0) { - throw new Error("낙찰된 업체가 없습니다.") - } - - // 일반/매각 입찰의 경우 비율 합계 100% 검증 - const contractType = bidding.contractType - if (contractType === 'general' || contractType === 'sale') { - const totalRatio = winnerCompaniesRaw.reduce((sum, company) => - sum + (Number(company.awardRatio) || 0), 0) - - if (totalRatio !== 100) { - throw new Error(`일반/매각 입찰의 경우 비율 합계가 100%여야 합니다. 현재 합계: ${totalRatio}%`) - } - } - - // 4. 낙찰된 업체들의 입찰 데이터 조회 (발주비율 적용) - type POItem = { - prItemId: number - proposedDeliveryDate: string | null - bidUnitPrice: string | null - bidAmount: string | null - currency: string | null - itemNumber: string | null - itemInfo: string | null - materialDescription: string | null - quantity: string | null - quantityUnit: string | null - finalQuantity: number - finalAmount: number - awardRatio: number - vendorCode: string | null - vendorName: string | null - companyId: number - } - const poItems: POItem[] = [] - for (const winner of winnerCompaniesRaw) { - const awardRatio = (Number(winner.awardRatio) || 100) / 100 - - const companyBids = await db.select({ - prItemId: companyPrItemBids.prItemId, - proposedDeliveryDate: companyPrItemBids.proposedDeliveryDate, - bidUnitPrice: companyPrItemBids.bidUnitPrice, - bidAmount: companyPrItemBids.bidAmount, - currency: companyPrItemBids.currency, - // PR 아이템 정보 - itemNumber: prItemsForBidding.itemNumber, - itemInfo: prItemsForBidding.itemInfo, - materialDescription: prItemsForBidding.materialDescription, - quantity: prItemsForBidding.quantity, - quantityUnit: prItemsForBidding.quantityUnit, - }) - .from(companyPrItemBids) - .leftJoin(prItemsForBidding, eq(companyPrItemBids.prItemId, prItemsForBidding.id)) - .where(eq(companyPrItemBids.biddingCompanyId, winner.id)) - - // 발주비율 적용하여 PO 아이템 생성 (중량 제외) - for (const bid of companyBids) { - const originalQuantity = Number(bid.quantity) || 0 - const bidUnitPrice = Number(bid.bidUnitPrice) || 0 - - const finalQuantity = originalQuantity * awardRatio - const finalAmount = finalQuantity * bidUnitPrice - - poItems.push({ - ...bid, - finalQuantity, - finalAmount, - awardRatio, - vendorCode: winner.vendorCode, - vendorName: winner.vendorName, - companyId: winner.companyId, - } as POItem) - } - } - - // 5. PO 데이터 구성 (bidding condition 정보와 발주비율 적용된 데이터 사용) - const poData = { - T_Bidding_HEADER: winnerCompaniesRaw.map((company) => ({ - ANFNR: bidding.biddingNumber, - LIFNR: company.vendorCode || `VENDOR${company.companyId}`, - ZPROC_IND: 'A', // 구매 처리 상태 - ANGNR: bidding.biddingNumber, - WAERS: bidding.currency || 'KRW', - ZTERM: biddingCondition?.paymentTerms || '0001', // 지급조건 - INCO1: biddingCondition?.incoterms || 'FOB', // Incoterms - INCO2: biddingCondition?.destinationPort || biddingCondition?.shippingPort || 'Seoul, Korea', - MWSKZ: biddingCondition?.taxConditions || 'V0', // 세금 코드 - LANDS: 'KR', - ZRCV_DT: getCurrentSAPDate(), - ZATTEN_IND: 'Y', - IHRAN: getCurrentSAPDate(), - TEXT: `PO from Bidding: ${bidding.title}`, - })), - T_Bidding_ITEM: poItems.map((item, index) => ({ - ANFNR: bidding.biddingNumber, - ANFPS: (index + 1).toString().padStart(5, '0'), - LIFNR: item.vendorCode || `VENDOR${item.companyId}`, - NETPR: item.bidUnitPrice?.toString() || '0', - PEINH: '1', - BPRME: item.quantityUnit || 'EA', - NETWR: item.finalAmount?.toString() || '0', - BRTWR: (Number(item.finalAmount || 0) * 1.1).toString(), // 10% 부가세 가정 - LFDAT: item.proposedDeliveryDate ? new Date(item.proposedDeliveryDate).toISOString().split('T')[0] : getCurrentSAPDate(), - })), - T_PR_RETURN: [{ - ANFNR: bidding.biddingNumber, - ANFPS: '00001', - EBELN: `PR${bidding.biddingNumber}`, - EBELP: '00001', - MSGTY: 'S', - MSGTXT: 'Success' - }] - } - - // 3. SAP으로 PO 전송 - console.log('SAP으로 PO 전송할 poData', poData) - const result = await createPurchaseOrder(poData) - - if (!result.success) { - throw new Error(result.message) - } - - return { success: true, message: result.message } - - } catch (error) { - console.error('TO PO 실패:', error) - throw new Error(error instanceof Error ? error.message : 'PO 전송에 실패했습니다.') - } -} - -// 낙찰된 업체들의 상세 정보 조회 (발주비율에 따른 계산 포함) -export async function getWinnerDetails(biddingId: number) { - try { - // 1. 입찰 정보 조회 (contractType 포함) - const biddingInfo = await db.select({ - contractType: biddings.contractType, - }) - .from(biddings) - .where(eq(biddings.id, biddingId)) - .limit(1) - - if (!biddingInfo || biddingInfo.length === 0) { - return { success: false, error: '입찰 정보를 찾을 수 없습니다.' } - } - - // 2. 낙찰된 업체들 조회 - const winnerCompanies = await db.select({ - id: biddingCompanies.id, - companyId: biddingCompanies.companyId, - finalQuoteAmount: biddingCompanies.finalQuoteAmount, - awardRatio: biddingCompanies.awardRatio, - vendorName: vendors.vendorName, - vendorCode: vendors.vendorCode, - contractType: biddingInfo[0].contractType, - }) - .from(biddingCompanies) - .leftJoin(vendors, eq(biddingCompanies.companyId, vendors.id)) - .where( - and( - eq(biddingCompanies.biddingId, biddingId), - eq(biddingCompanies.isWinner, true) - ) - ) - - if (winnerCompanies.length === 0) { - return { success: false, error: '낙찰된 업체가 없습니다.' } - } - - // 일반/매각 입찰의 경우 비율 합계 100% 검증 - const contractType = biddingInfo[0].contractType - if (contractType === 'general' || contractType === 'sale') { - const totalRatio = winnerCompanies.reduce((sum, company) => - sum + (Number(company.awardRatio) || 0), 0) - - if (totalRatio !== 100) { - return { success: false, error: `일반/매각 입찰의 경우 비율 합계가 100%여야 합니다. 현재 합계: ${totalRatio}%` } - } - } - - // 2. 각 낙찰 업체의 입찰 품목 정보 조회 - const winnerDetails = [] - - for (const winner of winnerCompanies) { - // 업체의 입찰 품목 정보 조회 - const companyBids = await db.select({ - prItemId: companyPrItemBids.prItemId, - proposedDeliveryDate: companyPrItemBids.proposedDeliveryDate, - bidUnitPrice: companyPrItemBids.bidUnitPrice, - bidAmount: companyPrItemBids.bidAmount, - currency: companyPrItemBids.currency, - // PR 아이템 정보 - itemNumber: prItemsForBidding.itemNumber, - itemInfo: prItemsForBidding.itemInfo, - materialDescription: prItemsForBidding.materialDescription, - quantity: prItemsForBidding.quantity, - quantityUnit: prItemsForBidding.quantityUnit, - }) - .from(companyPrItemBids) - .leftJoin(prItemsForBidding, eq(companyPrItemBids.prItemId, prItemsForBidding.id)) - .where(eq(companyPrItemBids.biddingCompanyId, winner.id)) - - // 발주비율에 따른 계산 (백분율을 실제 비율로 변환, 중량 제외) - const awardRatio = (Number(winner.awardRatio) || 100) / 100 - const calculatedItems = companyBids.map(bid => { - const originalQuantity = Number(bid.quantity) || 0 - const bidUnitPrice = Number(bid.bidUnitPrice) || 0 - - // 발주비율에 따른 최종 수량 계산 - const finalQuantity = originalQuantity * awardRatio - const finalWeight = 0 // 중량 제외 - const finalAmount = finalQuantity * bidUnitPrice - - return { - ...bid, - finalQuantity, - finalWeight, - finalAmount, - awardRatio, - } - }) - - // 업체 총 견적가 계산 - const totalFinalAmount = calculatedItems.reduce((sum, item) => sum + item.finalAmount, 0) - - winnerDetails.push({ - ...winner, - items: calculatedItems, - totalFinalAmount, - awardRatio: Number(winner.awardRatio) || 1, - }) - } - - return { - success: true, - data: winnerDetails - } - - } catch (error) { - console.error('Winner details 조회 실패:', error) - return { - success: false, - error: '낙찰 업체 상세 정보 조회에 실패했습니다.' - } - } -} +"use server" + +import db from "@/db/db" +import { eq, and } from "drizzle-orm" +import { + biddings, + biddingCompanies, + prItemsForBidding, + companyPrItemBids, + vendors, + generalContracts, + generalContractItems, + biddingConditions, + biddingDocuments, + users +} from "@/db/schema" +import { createPurchaseOrder } from "@/lib/soap/ecc/send/create-po" +import { getCurrentSAPDate } from "@/lib/soap/utils" +import { generateContractNumber } from "@/lib/general-contracts/service" +import { saveFile } from "@/lib/file-stroage" + +// TO Contract +export async function transmitToContract(biddingId: number, userId: number) { + try { + // 1. 입찰 정보 조회 (단순 쿼리) + const bidding = await db.select() + .from(biddings) + .where(eq(biddings.id, biddingId)) + .limit(1) + + if (!bidding || bidding.length === 0) { + throw new Error("입찰 정보를 찾을 수 없습니다.") + } + + const biddingData = bidding[0] + + // 2. 입찰 조건 정보 조회 + const biddingConditionData = await db.select() + .from(biddingConditions) + .where(eq(biddingConditions.biddingId, biddingId)) + .limit(1) + + const biddingCondition = biddingConditionData.length > 0 ? biddingConditionData[0] : null + + // 3. 낙찰된 업체들 조회 (biddingCompanies.id 포함) + const winnerCompaniesData = await db.select({ + id: biddingCompanies.id, + companyId: biddingCompanies.companyId, + finalQuoteAmount: biddingCompanies.finalQuoteAmount, + awardRatio: biddingCompanies.awardRatio, + vendorCode: vendors.vendorCode, + vendorName: vendors.vendorName + }) + .from(biddingCompanies) + .leftJoin(vendors, eq(biddingCompanies.companyId, vendors.id)) + .where( + and( + eq(biddingCompanies.biddingId, biddingId), + eq(biddingCompanies.isWinner, true) + ) + ) + + // 상태 검증 + if (biddingData.status !== 'vendor_selected') { + throw new Error("업체 선정이 완료되지 않은 입찰입니다.") + } + + // 낙찰된 업체 검증 + if (winnerCompaniesData.length === 0) { + throw new Error("낙찰된 업체가 없습니다.") + } + + // 일반/매각 입찰의 경우 비율 합계 100% 검증 + const contractType = biddingData.contractType + if (contractType === 'general' || contractType === 'sale') { + const totalRatio = winnerCompaniesData.reduce((sum, company) => + sum + (Number(company.awardRatio) || 0), 0) + + if (totalRatio !== 100) { + throw new Error(`일반/매각 입찰의 경우 비율 합계가 100%여야 합니다. 현재 합계: ${totalRatio}%`) + } + } + + for (const winnerCompany of winnerCompaniesData) { + // winnerCompany에서 직접 정보 사용 + const awardRatio = (Number(winnerCompany.awardRatio) || 100) / 100 + const biddingCompanyId = winnerCompany.id + + // 현재 winnerCompany의 입찰 데이터 조회 + const companyBids = await db.select({ + prItemId: companyPrItemBids.prItemId, + proposedDeliveryDate: companyPrItemBids.proposedDeliveryDate, + bidUnitPrice: companyPrItemBids.bidUnitPrice, + bidAmount: companyPrItemBids.bidAmount, + currency: companyPrItemBids.currency, + // PR 아이템 정보도 함께 조회 + itemNumber: prItemsForBidding.itemNumber, + itemInfo: prItemsForBidding.itemInfo, + materialDescription: prItemsForBidding.materialDescription, + quantity: prItemsForBidding.quantity, + quantityUnit: prItemsForBidding.quantityUnit, + }) + .from(companyPrItemBids) + .leftJoin(prItemsForBidding, eq(companyPrItemBids.prItemId, prItemsForBidding.id)) + .where(eq(companyPrItemBids.biddingCompanyId, biddingCompanyId)) + + // 발주비율에 따른 최종 계약금액 계산 + let totalContractAmount = 0 + if (companyBids.length > 0) { + for (const bid of companyBids) { + const originalQuantity = Number(bid.quantity) || 0 + const bidUnitPrice = Number(bid.bidUnitPrice) || 0 + const finalQuantity = originalQuantity * awardRatio + const finalAmount = finalQuantity * bidUnitPrice + totalContractAmount += finalAmount + } + } + + // 계약 번호 자동 생성 (실제 규칙에 맞게) + const contractNumber = await generateContractNumber(userId.toString(), biddingData.contractType) + console.log('Generated contractNumber:', contractNumber) + + // general-contract 생성 (발주비율 계산된 최종 금액 사용) + const contractResult = await db.insert(generalContracts).values({ + contractNumber, + revision: 0, + contractSourceType: 'bid', // 입찰에서 생성됨 + status: 'Draft', + category: biddingData.contractType || 'general', + name: biddingData.title, + vendorId: winnerCompany.companyId, + linkedBidNumber: biddingData.biddingNumber, + contractAmount: totalContractAmount ? totalContractAmount.toString() as any : null, // 발주비율 계산된 최종 금액 사용 + startDate: biddingData.contractStartDate || null, + endDate: biddingData.contractEndDate || null, + currency: biddingData.currency || 'KRW', + // 계약 조건 정보 추가 + paymentTerm: biddingCondition?.paymentTerms || null, + taxType: biddingCondition?.taxConditions || 'V0', + deliveryTerm: biddingCondition?.incoterms || 'FOB', + shippingLocation: biddingCondition?.shippingPort || null, + dischargeLocation: biddingCondition?.destinationPort || null, + registeredById: userId, + lastUpdatedById: userId, + }).returning({ id: generalContracts.id }) + console.log('contractResult', contractResult) + const contractId = contractResult[0].id + + // 현재 winnerCompany의 품목정보 생성 (발주비율 적용) + if (companyBids.length > 0) { + console.log(`Creating ${companyBids.length} contract items for winner company ${winnerCompany.companyId} with award ratio ${awardRatio}`) + for (const bid of companyBids) { + // 발주비율에 따른 최종 수량 계산 (중량 제외) + const originalQuantity = Number(bid.quantity) || 0 + const bidUnitPrice = Number(bid.bidUnitPrice) || 0 + + const finalQuantity = originalQuantity * awardRatio + const finalAmount = finalQuantity * bidUnitPrice + + await db.insert(generalContractItems).values({ + contractId: contractId, + itemCode: bid.itemNumber || '', + itemInfo: bid.itemInfo || '', + specification: bid.materialDescription || '', + quantity: finalQuantity || null, + quantityUnit: bid.quantityUnit || '', + totalWeight: null, // 중량 정보 제외 + weightUnit: '', // 중량 단위 제외 + contractDeliveryDate: bid.proposedDeliveryDate || null, + contractUnitPrice: bid.bidUnitPrice || null, + contractAmount: finalAmount ? finalAmount.toString() as any : null, + contractCurrency: bid.currency || biddingData.currency || 'KRW', + }) + } + console.log(`Created ${companyBids.length} contract items for winner company ${winnerCompany.companyId}`) + } else { + console.log(`No bid data found for winner company ${winnerCompany.companyId}`) + } + } + + return { success: true, message: `${winnerCompaniesData.length}개의 계약서가 생성되었습니다.` } + + } catch (error) { + console.error('TO Contract 실패:', error) + throw new Error(error instanceof Error ? error.message : '계약서 생성에 실패했습니다.') + } +} + +// TO PO +export async function transmitToPO(biddingId: number) { + try { + // 1. 입찰 정보 조회 + const biddingData = await db.select() + .from(biddings) + .where(eq(biddings.id, biddingId)) + .limit(1) + + if (!biddingData || biddingData.length === 0) { + throw new Error("입찰 정보를 찾을 수 없습니다.") + } + + const bidding = biddingData[0] + + if (bidding.status !== 'vendor_selected') { + throw new Error("업체 선정이 완료되지 않은 입찰입니다.") + } + + // 2. 입찰 조건 정보 조회 + const biddingConditionData = await db.select() + .from(biddingConditions) + .where(eq(biddingConditions.biddingId, biddingId)) + .limit(1) + + const biddingCondition = biddingConditionData.length > 0 ? biddingConditionData[0] : null + + // 3. 낙찰된 업체들 조회 (발주비율 포함) + const winnerCompaniesRaw = await db.select({ + id: biddingCompanies.id, + companyId: biddingCompanies.companyId, + finalQuoteAmount: biddingCompanies.finalQuoteAmount, + awardRatio: biddingCompanies.awardRatio, + vendorCode: vendors.vendorCode, + vendorName: vendors.vendorName + }) + .from(biddingCompanies) + .leftJoin(vendors, eq(biddingCompanies.companyId, vendors.id)) + .where( + and( + eq(biddingCompanies.biddingId, biddingId), + eq(biddingCompanies.isWinner, true) + ) + ) + + if (winnerCompaniesRaw.length === 0) { + throw new Error("낙찰된 업체가 없습니다.") + } + + // 일반/매각 입찰의 경우 비율 합계 100% 검증 + const contractType = bidding.contractType + if (contractType === 'general' || contractType === 'sale') { + const totalRatio = winnerCompaniesRaw.reduce((sum, company) => + sum + (Number(company.awardRatio) || 0), 0) + + if (totalRatio !== 100) { + throw new Error(`일반/매각 입찰의 경우 비율 합계가 100%여야 합니다. 현재 합계: ${totalRatio}%`) + } + } + + // 4. 낙찰된 업체들의 입찰 데이터 조회 (발주비율 적용) + type POItem = { + prItemId: number + proposedDeliveryDate: string | null + bidUnitPrice: string | null + bidAmount: string | null + currency: string | null + itemNumber: string | null + itemInfo: string | null + materialDescription: string | null + quantity: string | null + quantityUnit: string | null + finalQuantity: number + finalAmount: number + awardRatio: number + vendorCode: string | null + vendorName: string | null + companyId: number + } + const poItems: POItem[] = [] + for (const winner of winnerCompaniesRaw) { + const awardRatio = (Number(winner.awardRatio) || 100) / 100 + + const companyBids = await db.select({ + prItemId: companyPrItemBids.prItemId, + proposedDeliveryDate: companyPrItemBids.proposedDeliveryDate, + bidUnitPrice: companyPrItemBids.bidUnitPrice, + bidAmount: companyPrItemBids.bidAmount, + currency: companyPrItemBids.currency, + // PR 아이템 정보 + itemNumber: prItemsForBidding.itemNumber, + itemInfo: prItemsForBidding.itemInfo, + materialDescription: prItemsForBidding.materialDescription, + quantity: prItemsForBidding.quantity, + quantityUnit: prItemsForBidding.quantityUnit, + }) + .from(companyPrItemBids) + .leftJoin(prItemsForBidding, eq(companyPrItemBids.prItemId, prItemsForBidding.id)) + .where(eq(companyPrItemBids.biddingCompanyId, winner.id)) + + // 발주비율 적용하여 PO 아이템 생성 (중량 제외) + for (const bid of companyBids) { + const originalQuantity = Number(bid.quantity) || 0 + const bidUnitPrice = Number(bid.bidUnitPrice) || 0 + + const finalQuantity = originalQuantity * awardRatio + const finalAmount = finalQuantity * bidUnitPrice + + poItems.push({ + ...bid, + finalQuantity, + finalAmount, + awardRatio, + vendorCode: winner.vendorCode, + vendorName: winner.vendorName, + companyId: winner.companyId, + } as POItem) + } + } + + // 5. PO 데이터 구성 (bidding condition 정보와 발주비율 적용된 데이터 사용) + const poData = { + T_Bidding_HEADER: winnerCompaniesRaw.map((company) => ({ + ANFNR: bidding.biddingNumber, + LIFNR: company.vendorCode || `VENDOR${company.companyId}`, + ZPROC_IND: 'A', // 구매 처리 상태 + ANGNR: bidding.biddingNumber, + WAERS: bidding.currency || 'KRW', + ZTERM: biddingCondition?.paymentTerms || '0001', // 지급조건 + INCO1: biddingCondition?.incoterms || 'FOB', // Incoterms + INCO2: biddingCondition?.destinationPort || biddingCondition?.shippingPort || 'Seoul, Korea', + MWSKZ: biddingCondition?.taxConditions || 'V0', // 세금 코드 + LANDS: 'KR', + ZRCV_DT: getCurrentSAPDate(), + ZATTEN_IND: 'Y', + IHRAN: getCurrentSAPDate(), + TEXT: `PO from Bidding: ${bidding.title}`, + })), + T_Bidding_ITEM: poItems.map((item, index) => ({ + ANFNR: bidding.biddingNumber, + ANFPS: (index + 1).toString().padStart(5, '0'), + LIFNR: item.vendorCode || `VENDOR${item.companyId}`, + NETPR: item.bidUnitPrice?.toString() || '0', + PEINH: '1', + BPRME: item.quantityUnit || 'EA', + NETWR: item.finalAmount?.toString() || '0', + BRTWR: (Number(item.finalAmount || 0) * 1.1).toString(), // 10% 부가세 가정 + LFDAT: item.proposedDeliveryDate ? new Date(item.proposedDeliveryDate).toISOString().split('T')[0] : getCurrentSAPDate(), + })), + T_PR_RETURN: [{ + ANFNR: bidding.biddingNumber, + ANFPS: '00001', + EBELN: `PR${bidding.biddingNumber}`, + EBELP: '00001', + MSGTY: 'S', + MSGTXT: 'Success' + }] + } + + // 3. SAP으로 PO 전송 + console.log('SAP으로 PO 전송할 poData', poData) + const result = await createPurchaseOrder(poData) + + if (!result.success) { + throw new Error(result.message) + } + + return { success: true, message: result.message } + + } catch (error) { + console.error('TO PO 실패:', error) + throw new Error(error instanceof Error ? error.message : 'PO 전송에 실패했습니다.') + } +} + +// 낙찰된 업체들의 상세 정보 조회 (발주비율에 따른 계산 포함) +export async function getWinnerDetails(biddingId: number) { + try { + // 1. 입찰 정보 조회 (contractType 포함) + const biddingInfo = await db.select({ + contractType: biddings.contractType, + }) + .from(biddings) + .where(eq(biddings.id, biddingId)) + .limit(1) + + if (!biddingInfo || biddingInfo.length === 0) { + return { success: false, error: '입찰 정보를 찾을 수 없습니다.' } + } + + // 2. 낙찰된 업체들 조회 + const winnerCompanies = await db.select({ + id: biddingCompanies.id, + companyId: biddingCompanies.companyId, + finalQuoteAmount: biddingCompanies.finalQuoteAmount, + awardRatio: biddingCompanies.awardRatio, + vendorName: vendors.vendorName, + vendorCode: vendors.vendorCode, + contractType: biddingInfo[0].contractType, + }) + .from(biddingCompanies) + .leftJoin(vendors, eq(biddingCompanies.companyId, vendors.id)) + .where( + and( + eq(biddingCompanies.biddingId, biddingId), + eq(biddingCompanies.isWinner, true) + ) + ) + + if (winnerCompanies.length === 0) { + return { success: false, error: '낙찰된 업체가 없습니다.' } + } + + // 일반/매각 입찰의 경우 비율 합계 100% 검증 + const contractType = biddingInfo[0].contractType + if (contractType === 'general' || contractType === 'sale') { + const totalRatio = winnerCompanies.reduce((sum, company) => + sum + (Number(company.awardRatio) || 0), 0) + + if (totalRatio !== 100) { + return { success: false, error: `일반/매각 입찰의 경우 비율 합계가 100%여야 합니다. 현재 합계: ${totalRatio}%` } + } + } + + // 2. 각 낙찰 업체의 입찰 품목 정보 조회 + const winnerDetails = [] + + for (const winner of winnerCompanies) { + // 업체의 입찰 품목 정보 조회 + const companyBids = await db.select({ + prItemId: companyPrItemBids.prItemId, + proposedDeliveryDate: companyPrItemBids.proposedDeliveryDate, + bidUnitPrice: companyPrItemBids.bidUnitPrice, + bidAmount: companyPrItemBids.bidAmount, + currency: companyPrItemBids.currency, + // PR 아이템 정보 + itemNumber: prItemsForBidding.itemNumber, + itemInfo: prItemsForBidding.itemInfo, + materialDescription: prItemsForBidding.materialDescription, + quantity: prItemsForBidding.quantity, + quantityUnit: prItemsForBidding.quantityUnit, + }) + .from(companyPrItemBids) + .leftJoin(prItemsForBidding, eq(companyPrItemBids.prItemId, prItemsForBidding.id)) + .where(eq(companyPrItemBids.biddingCompanyId, winner.id)) + + // 발주비율에 따른 계산 (백분율을 실제 비율로 변환, 중량 제외) + const awardRatio = (Number(winner.awardRatio) || 100) / 100 + const calculatedItems = companyBids.map(bid => { + const originalQuantity = Number(bid.quantity) || 0 + const bidUnitPrice = Number(bid.bidUnitPrice) || 0 + + // 발주비율에 따른 최종 수량 계산 + const finalQuantity = originalQuantity * awardRatio + const finalWeight = 0 // 중량 제외 + const finalAmount = finalQuantity * bidUnitPrice + + return { + ...bid, + finalQuantity, + finalWeight, + finalAmount, + awardRatio, + } + }) + + // 업체 총 견적가 계산 + const totalFinalAmount = calculatedItems.reduce((sum, item) => sum + item.finalAmount, 0) + + winnerDetails.push({ + ...winner, + items: calculatedItems, + totalFinalAmount, + awardRatio: Number(winner.awardRatio) || 1, + }) + } + + return { + success: true, + data: winnerDetails + } + + } catch (error) { + console.error('Winner details 조회 실패:', error) + return { + success: false, + error: '낙찰 업체 상세 정보 조회에 실패했습니다.' + } + } +} + +// 폐찰하기 액션 +export async function bidClosureAction( + biddingId: number, + formData: { + description: string + files: File[] + }, + userId: string +) { + try { + const userName = await getUserNameById(userId) + + return await db.transaction(async (tx) => { + // 1. 입찰 정보 확인 + const [existingBidding] = await tx + .select() + .from(biddings) + .where(eq(biddings.id, biddingId)) + .limit(1) + + if (!existingBidding) { + return { + success: false, + error: '입찰 정보를 찾을 수 없습니다.' + } + } + + // 2. 유찰 상태인지 확인 + if (existingBidding.status !== 'bidding_disposal') { + return { + success: false, + error: '유찰 상태인 입찰만 폐찰할 수 있습니다.' + } + } + + // 3. 입찰 상태를 폐찰로 변경하고 설명 저장 + await tx + .update(biddings) + .set({ + status: 'bid_closure', + description: formData.description, + updatedAt: new Date(), + updatedBy: userName, + }) + .where(eq(biddings.id, biddingId)) + + // 4. 첨부파일들 저장 (evaluation_doc로 저장) + if (formData.files && formData.files.length > 0) { + for (const file of formData.files) { + try { + const saveResult = await saveFile({ + file, + directory: `biddings/${biddingId}/closure-documents`, + originalName: file.name, + userId + }) + + if (saveResult.success) { + await tx.insert(biddingDocuments).values({ + biddingId, + documentType: 'evaluation_doc', + fileName: saveResult.fileName!, + originalFileName: saveResult.originalName!, + fileSize: saveResult.fileSize!, + mimeType: file.type, + filePath: saveResult.publicPath!, + title: `폐찰 문서 - ${file.name}`, + description: formData.description, + isPublic: false, + isRequired: false, + uploadedBy: userName, + }) + } else { + console.error(`Failed to save closure file: ${file.name}`, saveResult.error) + } + } catch (error) { + console.error(`Error saving closure file: ${file.name}`, error) + } + } + } + + return { + success: true, + message: '폐찰이 완료되었습니다.' + } + }) + + } catch (error) { + console.error('폐찰 실패:', error) + return { + success: false, + error: error instanceof Error ? error.message : '폐찰 중 오류가 발생했습니다.' + } + } +} + +// 사용자 이름 조회 헬퍼 함수 +async function getUserNameById(userId: string): Promise { + try { + const user = await db + .select({ name: users.name }) + .from(users) + .where(eq(users.id, parseInt(userId))) + .limit(1) + + return user[0]?.name || userId + } catch (error) { + console.error('Failed to get user name:', error) + return userId + } +} diff --git a/lib/bidding/bidding-notice-editor.tsx b/lib/bidding/bidding-notice-editor.tsx index 03b993b9..8d0f1e35 100644 --- a/lib/bidding/bidding-notice-editor.tsx +++ b/lib/bidding/bidding-notice-editor.tsx @@ -8,15 +8,30 @@ import { Label } from '@/components/ui/label' import { useToast } from '@/hooks/use-toast' import { Save, RefreshCw } from 'lucide-react' import { BiddingNoticeTemplate } from '@/db/schema/bidding' -import { saveBiddingNoticeTemplate } from './service' +import { saveBiddingNoticeTemplate, saveBiddingNotice } from './service' import TiptapEditor from '@/components/qna/tiptap-editor' interface BiddingNoticeEditorProps { initialData: BiddingNoticeTemplate | null + biddingId?: number // 입찰 ID (있으면 일반 입찰공고, 없으면 템플릿) + templateType?: string // 템플릿 타입 (템플릿 저장 시 사용) + onSaveSuccess?: () => void // 저장 성공 시 콜백 + onTemplateUpdate?: (template: BiddingNoticeTemplate) => void // 템플릿 업데이트 콜백 } -export function BiddingNoticeEditor({ initialData }: BiddingNoticeEditorProps) { - const [title, setTitle] = useState(initialData?.title || '표준 입찰공고문') +export function BiddingNoticeEditor({ initialData, biddingId, templateType, onSaveSuccess, onTemplateUpdate }: BiddingNoticeEditorProps) { + const getDefaultTitle = (type?: string) => { + switch (type) { + case 'facility': + return '시설재 입찰공고문' + case 'unit_price': + return '단가계약 입찰공고문' + default: + return '표준 입찰공고문' + } + } + + const [title, setTitle] = useState(initialData?.title || getDefaultTitle(templateType)) const [content, setContent] = useState(initialData?.content || getDefaultTemplate()) const [isPending, startTransition] = useTransition() const { toast } = useToast() @@ -43,12 +58,47 @@ export function BiddingNoticeEditor({ initialData }: BiddingNoticeEditorProps) { startTransition(async () => { try { - await saveBiddingNoticeTemplate({ title, content }) - toast({ - title: '성공', - description: '입찰공고문 템플릿이 저장되었습니다.', - }) + if (biddingId) { + // 일반 입찰공고 저장 + await saveBiddingNotice(biddingId, { title, content }) + toast({ + title: '성공', + description: '입찰공고문이 저장되었습니다.', + }) + } else { + // 템플릿 저장 + if (!templateType) { + toast({ + title: '오류', + description: '템플릿 타입이 지정되지 않았습니다.', + variant: 'destructive', + }) + return + } + + const savedTemplate = await saveBiddingNoticeTemplate({ title, content, type: templateType }) + toast({ + title: '성공', + description: '입찰공고문 템플릿이 저장되었습니다.', + }) + + // 템플릿 업데이트 콜백 호출 + if (onTemplateUpdate && savedTemplate) { + // 저장된 템플릿 데이터를 가져와서 콜백 호출 + const updatedTemplate = { + ...initialData, + title, + content, + type: templateType, + updatedAt: new Date(), + } as BiddingNoticeTemplate + onTemplateUpdate(updatedTemplate) + } + } router.refresh() + + // 저장 성공 시 콜백 호출 + onSaveSuccess?.() } catch (error) { toast({ title: '오류', @@ -59,16 +109,16 @@ export function BiddingNoticeEditor({ initialData }: BiddingNoticeEditorProps) { }) } - const handleReset = () => { - if (confirm('기본 템플릿으로 초기화하시겠습니까? 현재 내용은 삭제됩니다.')) { - setTitle('표준 입찰공고문') - setContent(getDefaultTemplate()) - toast({ - title: '초기화 완료', - description: '기본 템플릿으로 초기화되었습니다.', - }) - } - } + // const handleReset = () => { + // if (confirm('기본 템플릿으로 초기화하시겠습니까? 현재 내용은 삭제됩니다.')) { + // setTitle(getDefaultTitle(templateType)) + // setContent(getDefaultTemplate()) + // toast({ + // title: '초기화 완료', + // description: '기본 템플릿으로 초기화되었습니다.', + // }) + // } + // } return (
@@ -117,13 +167,13 @@ export function BiddingNoticeEditor({ initialData }: BiddingNoticeEditorProps) { )} - + */} {initialData && (
@@ -131,15 +181,6 @@ export function BiddingNoticeEditor({ initialData }: BiddingNoticeEditorProps) {
)}
- - {/* 미리보기 힌트 */} -
-

- 💡 사용 팁: - 이 템플릿은 실제 입찰 공고 작성 시 기본값으로 사용됩니다. - 회사 정보, 표준 조건, 서식 등을 미리 작성해두면 편리합니다. -

-
) } diff --git a/lib/bidding/bidding-notice-template-manager.tsx b/lib/bidding/bidding-notice-template-manager.tsx new file mode 100644 index 00000000..3426020f --- /dev/null +++ b/lib/bidding/bidding-notice-template-manager.tsx @@ -0,0 +1,63 @@ +'use client' + +import { useState } from 'react' +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card' +import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs' +import { BiddingNoticeEditor } from './bidding-notice-editor' +import { BiddingNoticeTemplate } from '@/db/schema/bidding' +import { biddingNoticeTypeLabels } from '@/db/schema/bidding' + +interface BiddingNoticeTemplateManagerProps { + initialTemplates: Record +} + +export function BiddingNoticeTemplateManager({ initialTemplates }: BiddingNoticeTemplateManagerProps) { + const [activeTab, setActiveTab] = useState('standard') + const [templates, setTemplates] = useState(initialTemplates) + + const handleTemplateUpdate = (type: string, template: BiddingNoticeTemplate) => { + setTemplates(prev => ({ + ...prev, + [type]: template + })) + } + + const templateTypes = [ + { key: 'standard', label: biddingNoticeTypeLabels.standard }, + { key: 'facility', label: biddingNoticeTypeLabels.facility }, + { key: 'unit_price', label: biddingNoticeTypeLabels.unit_price } + ] + + return ( + + + {templateTypes.map(({ key, label }) => ( + + {label} + + ))} + + + {templateTypes.map(({ key, label }) => ( + + + + {label} 입찰공고문 템플릿 + + {label} 타입의 입찰공고문 템플릿을 작성하고 관리할 수 있습니다. + 이 템플릿은 실제 입찰 공고 작성 시 기본 양식으로 사용됩니다. + + + + handleTemplateUpdate(key, template)} + /> + + + + ))} + + ) +} diff --git a/lib/bidding/detail/bidding-actions.ts b/lib/bidding/detail/bidding-actions.ts new file mode 100644 index 00000000..70bba1c3 --- /dev/null +++ b/lib/bidding/detail/bidding-actions.ts @@ -0,0 +1,227 @@ +'use server' + +import db from '@/db/db' +import { biddings, biddingCompanies, companyPrItemBids } from '@/db/schema/bidding' +import { eq, and } from 'drizzle-orm' +import { revalidateTag, revalidatePath } from 'next/cache' +import { users } from '@/db/schema' + +// userId를 user.name으로 변환하는 유틸리티 함수 +async function getUserNameById(userId: string): Promise { + try { + const user = await db + .select({ name: users.name }) + .from(users) + .where(eq(users.id, parseInt(userId))) + .limit(1) + + return user[0]?.name || userId + } catch (error) { + console.error('Failed to get user name:', error) + return userId + } +} + +// 응찰 취소 서버 액션 (최종제출이 아닌 경우만 가능) +export async function cancelBiddingResponse( + biddingCompanyId: number, + userId: string +) { + try { + const userName = await getUserNameById(userId) + + return await db.transaction(async (tx) => { + // 1. 현재 상태 확인 (최종제출 여부) + const [company] = await tx + .select({ + isFinalSubmission: biddingCompanies.isFinalSubmission, + biddingId: biddingCompanies.biddingId, + }) + .from(biddingCompanies) + .where(eq(biddingCompanies.id, biddingCompanyId)) + .limit(1) + + if (!company) { + return { + success: false, + error: '업체 정보를 찾을 수 없습니다.' + } + } + + // 최종제출한 경우 취소 불가 + if (company.isFinalSubmission) { + return { + success: false, + error: '최종 제출된 응찰은 취소할 수 없습니다.' + } + } + + // 2. 응찰 데이터 초기화 + await tx + .update(biddingCompanies) + .set({ + finalQuoteAmount: null, + finalQuoteSubmittedAt: null, + isFinalSubmission: false, + invitationStatus: 'bidding_cancelled', // 응찰 취소 상태 + updatedAt: new Date() + }) + .where(eq(biddingCompanies.id, biddingCompanyId)) + + // 3. 품목별 견적 삭제 (본입찰 데이터) + await tx + .delete(companyPrItemBids) + .where( + and( + eq(companyPrItemBids.biddingCompanyId, biddingCompanyId), + eq(companyPrItemBids.isPreQuote, false) + ) + ) + + // 캐시 무효화 + revalidateTag(`bidding-${company.biddingId}`) + revalidateTag('quotation-vendors') + revalidateTag('quotation-details') + revalidatePath(`/partners/bid/${company.biddingId}`) + + return { + success: true, + message: '응찰이 취소되었습니다.' + } + }) + } catch (error) { + console.error('Failed to cancel bidding response:', error) + return { + success: false, + error: error instanceof Error ? error.message : '응찰 취소에 실패했습니다.' + } + } +} + +// 모든 벤더가 최종제출했는지 확인 +export async function checkAllVendorsFinalSubmitted(biddingId: number) { + try { + const companies = await db + .select({ + id: biddingCompanies.id, + isFinalSubmission: biddingCompanies.isFinalSubmission, + invitationStatus: biddingCompanies.invitationStatus, + }) + .from(biddingCompanies) + .where( + and( + eq(biddingCompanies.biddingId, biddingId), + eq(biddingCompanies.isBiddingInvited, true) // 본입찰 초대된 업체만 + ) + ) + + // 초대된 업체가 없으면 false + if (companies.length === 0) { + return { + allSubmitted: false, + totalCompanies: 0, + submittedCompanies: 0 + } + } + + // 모든 업체가 최종제출했는지 확인 + const submittedCompanies = companies.filter(c => c.isFinalSubmission).length + const allSubmitted = companies.every(c => c.isFinalSubmission) + + return { + allSubmitted, + totalCompanies: companies.length, + submittedCompanies + } + } catch (error) { + console.error('Failed to check all vendors final submitted:', error) + return { + allSubmitted: false, + totalCompanies: 0, + submittedCompanies: 0 + } + } +} + +// 개찰 서버 액션 (조기개찰/개찰 구분) +export async function performBidOpening( + biddingId: number, + userId: string, + isEarly: boolean = false // 조기개찰 여부 +) { + try { + const userName = await getUserNameById(userId) + + return await db.transaction(async (tx) => { + // 1. 입찰 정보 조회 + const [bidding] = await tx + .select({ + id: biddings.id, + status: biddings.status, + submissionEndDate: biddings.submissionEndDate, + }) + .from(biddings) + .where(eq(biddings.id, biddingId)) + .limit(1) + + if (!bidding) { + return { + success: false, + error: '입찰 정보를 찾을 수 없습니다.' + } + } + + // 2. 개찰 가능 여부 확인 (evaluation_of_bidding 상태에서만) + if (bidding.status !== 'evaluation_of_bidding') { + return { + success: false, + error: '입찰평가중 상태에서만 개찰할 수 있습니다.' + } + } + + // 3. 모든 벤더가 최종제출했는지 확인 + const checkResult = await checkAllVendorsFinalSubmitted(biddingId) + if (!checkResult.allSubmitted) { + return { + success: false, + error: `모든 벤더가 최종 제출해야 개찰할 수 있습니다. (${checkResult.submittedCompanies}/${checkResult.totalCompanies})` + } + } + + // 4. 조기개찰 여부 결정 + const now = new Date() + const submissionEndDate = bidding.submissionEndDate ? new Date(bidding.submissionEndDate) : null + const isBeforeDeadline = submissionEndDate && now < submissionEndDate + + // 마감일 전이면 조기개찰, 마감일 후면 일반 개찰 + const newStatus = (isEarly || isBeforeDeadline) ? 'early_bid_opening' : 'bid_opening' + + // 5. 입찰 상태 변경 + await tx + .update(biddings) + .set({ + status: newStatus, + updatedAt: new Date() + }) + .where(eq(biddings.id, biddingId)) + + // 캐시 무효화 + revalidateTag(`bidding-${biddingId}`) + revalidateTag('bidding-detail') + revalidatePath(`/evcp/bid/${biddingId}`) + + return { + success: true, + message: `${newStatus === 'early_bid_opening' ? '조기개찰' : '개찰'}이 완료되었습니다.`, + status: newStatus + } + }) + } catch (error) { + console.error('Failed to perform bid opening:', error) + return { + success: false, + error: error instanceof Error ? error.message : '개찰에 실패했습니다.' + } + } +} + diff --git a/lib/bidding/detail/service.ts b/lib/bidding/detail/service.ts index 404bc3cd..d58ded8e 100644 --- a/lib/bidding/detail/service.ts +++ b/lib/bidding/detail/service.ts @@ -81,6 +81,7 @@ export interface QuotationVendor { vendorId: number vendorName: string vendorCode: string + vendorEmail?: string // 벤더의 기본 이메일 contactPerson: string contactEmail: string contactPhone: string @@ -90,7 +91,7 @@ export interface QuotationVendor { isWinner: boolean | null // 낙찰여부 (null: 미정, true: 낙찰, false: 탈락) awardRatio: number | null // 발주비율 isBiddingParticipated: boolean | null // 본입찰 참여여부 - status: 'pending' | 'submitted' | 'selected' | 'rejected' + invitationStatus: 'pending' | 'pre_quote_sent' | 'pre_quote_accepted' | 'pre_quote_declined' | 'pre_quote_submitted' | 'bidding_sent' | 'bidding_accepted' | 'bidding_declined' | 'bidding_cancelled' | 'bidding_submitted' documents: Array<{ id: number fileName: string @@ -241,6 +242,7 @@ export async function getQuotationVendors(biddingId: number): Promise`CASE WHEN ${biddingCompanies.isWinner} THEN 100 ELSE 0 END`, awardRatio: biddingCompanies.awardRatio, isBiddingParticipated: biddingCompanies.isBiddingParticipated, - status: sql`CASE - WHEN ${biddingCompanies.isWinner} THEN 'selected' - WHEN ${biddingCompanies.finalQuoteSubmittedAt} IS NOT NULL THEN 'submitted' - WHEN ${biddingCompanies.respondedAt} IS NOT NULL THEN 'submitted' - ELSE 'pending' - END`, + invitationStatus: biddingCompanies.invitationStatus, }) .from(biddingCompanies) .leftJoin(vendors, eq(biddingCompanies.companyId, vendors.id)) @@ -272,6 +269,7 @@ export async function getQuotationVendors(biddingId: number): Promise { @@ -630,9 +629,10 @@ export async function createBiddingDetailVendor( const biddingCompanyResult = await tx.insert(biddingCompanies).values({ biddingId: biddingId, companyId: vendorId, - invitationStatus: 'pending', + invitationStatus: 'pending', // 초대 대기 isPreQuoteSelected: true, // 본입찰 등록 기본값 isWinner: null, // 미정 상태로 초기화 0916 + isPriceAdjustmentApplicableQuestion: isPriceAdjustmentApplicableQuestion ?? false, createdAt: new Date(), updatedAt: new Date(), }).returning({ id: biddingCompanies.id }) @@ -730,9 +730,8 @@ export async function markAsDisposal(biddingId: number, userId: string) { itemName: bidding.itemName, biddingType: bidding.biddingType, processedDate: new Date().toLocaleDateString('ko-KR'), - managerName: bidding.managerName, - managerEmail: bidding.managerEmail, - managerPhone: bidding.managerPhone, + bidPicName: bidding.bidPicName, + supplyPicName: bidding.supplyPicName, language: 'ko' } }) @@ -807,7 +806,7 @@ export async function registerBidding(biddingId: number, userId: string) { .update(biddingCompanies) .set({ isBiddingInvited: true, - invitationStatus: 'sent', + invitationStatus: 'bidding_sent', // 입찰 초대 발송 updatedAt: new Date() }) .where(and( @@ -834,9 +833,8 @@ export async function registerBidding(biddingId: number, userId: string) { submissionStartDate: bidding.submissionStartDate, submissionEndDate: bidding.submissionEndDate, biddingUrl: `${process.env.NEXT_PUBLIC_BASE_URL}/partners/bid/${biddingId}`, - managerName: bidding.managerName, - managerEmail: bidding.managerEmail, - managerPhone: bidding.managerPhone, + bidPicName: bidding.bidPicName, + supplyPicName: bidding.supplyPicName, language: 'ko' } }) @@ -945,9 +943,8 @@ export async function createRebidding(biddingId: number, userId: string) { submissionStartDate: originalBidding.submissionStartDate, submissionEndDate: originalBidding.submissionEndDate, biddingUrl: `${process.env.NEXT_PUBLIC_BASE_URL}/partners/bid/${biddingId}`, - managerName: originalBidding.managerName, - managerEmail: originalBidding.managerEmail, - managerPhone: originalBidding.managerPhone, + bidPicName: originalBidding.bidPicName, + supplyPicName: originalBidding.supplyPicName, language: 'ko' } }) @@ -1521,6 +1518,7 @@ export interface PartnersBiddingListItem { // biddings 정보 biddingId: number biddingNumber: string + originalBiddingNumber: string | null // 원입찰번호 revision: number | null projectName: string itemName: string @@ -1533,9 +1531,10 @@ export interface PartnersBiddingListItem { submissionStartDate: Date | null submissionEndDate: Date | null status: string - managerName: string | null - managerEmail: string | null - managerPhone: string | null + // 입찰담당자 + bidPicName: string | null + // 조달담당자 + supplyPicName: string | null currency: string budget: number | null isUrgent: boolean | null // 긴급여부 @@ -1572,6 +1571,7 @@ export async function getBiddingListForPartners(companyId: number): Promise 0) { - // 협력업체 정보 조회 + // 업체 정보 const companyInfo = await tx .select({ vendorName: vendors.vendorName, @@ -2051,37 +2063,59 @@ export async function updatePartnerAttendance( const companyName = companyInfo.length > 0 ? companyInfo[0].vendorName : '알 수 없음' - // 메일 발송 (템플릿 사용) - try { - const { sendEmail } = await import('@/lib/mail/sendEmail') - - await sendEmail({ - to: biddingInfo[0].managerEmail, - template: 'specification-meeting-attendance', - context: { - biddingNumber: biddingInfo[0].biddingNumber, - title: biddingInfo[0].title, - companyName: companyName, - attendeeCount: attendanceData.attendeeCount, - representativeName: attendanceData.representativeName, - representativePhone: attendanceData.representativePhone, - managerName: biddingInfo[0].managerName, - managerEmail: biddingInfo[0].managerEmail, - currentYear: new Date().getFullYear(), - language: 'ko' - } + // 사양설명회 상세 정보(담당자 email 포함) + const specificationMeetingInfo = await tx + .select({ + contactEmail: specificationMeetings.contactEmail, + meetingDate: specificationMeetings.meetingDate, + meetingTime: specificationMeetings.meetingTime, + location: specificationMeetings.location, }) - - console.log(`사양설명회 참석 알림 메일 발송 완료: ${biddingInfo[0].managerEmail}`) - } catch (emailError) { - console.error('메일 발송 실패:', emailError) - // 메일 발송 실패해도 참석 여부 업데이트는 성공으로 처리 + .from(specificationMeetings) + .where(eq(specificationMeetings.biddingId, biddingInfo[0].biddingId)) + .limit(1) + + const contactEmail = specificationMeetingInfo.length > 0 ? specificationMeetingInfo[0].contactEmail : null + + // 메일 발송 (템플릿 사용) + if (contactEmail) { + try { + const { sendEmail } = await import('@/lib/mail/sendEmail') + + await sendEmail({ + to: contactEmail, + template: 'specification-meeting-attendance', + context: { + biddingNumber: biddingInfo[0].biddingNumber, + title: biddingInfo[0].title, + companyName: companyName, + attendeeCount: attendanceData.attendeeCount, + representativeName: attendanceData.representativeName, + representativePhone: attendanceData.representativePhone, + bidPicName: biddingInfo[0].bidPicName, + supplyPicName: biddingInfo[0].supplyPicName, + meetingDate: specificationMeetingInfo[0]?.meetingDate, + meetingTime: specificationMeetingInfo[0]?.meetingTime, + location: specificationMeetingInfo[0]?.location, + contactEmail: contactEmail, + currentYear: new Date().getFullYear(), + language: 'ko' + } + }) + + console.log(`사양설명회 참석 알림 메일 발송 완료: ${contactEmail}`) + } catch (emailError) { + console.error('메일 발송 실패:', emailError) + // 메일 발송 실패해도 참석 여부 업데이트는 성공으로 처리 + } + } else { + console.warn('사양설명회 담당자 이메일이 없습니다.') } - + // 캐시 무효화 revalidateTag(`bidding-${biddingInfo[0].biddingId}`) revalidateTag('quotation-vendors') - + return { ...biddingInfo[0], companyName, diff --git a/lib/bidding/detail/table/bidding-detail-content.tsx b/lib/bidding/detail/table/bidding-detail-content.tsx index 895016a2..05c7d567 100644 --- a/lib/bidding/detail/table/bidding-detail-content.tsx +++ b/lib/bidding/detail/table/bidding-detail-content.tsx @@ -9,8 +9,17 @@ import { BiddingDetailItemsDialog } from './bidding-detail-items-dialog' import { BiddingDetailTargetPriceDialog } from './bidding-detail-target-price-dialog' import { BiddingPreQuoteItemDetailsDialog } from '../../../bidding/pre-quote/table/bidding-pre-quote-item-details-dialog' import { getPrItemsForBidding } from '../../../bidding/pre-quote/service' +import { checkAllVendorsFinalSubmitted, performBidOpening } from '../bidding-actions' import { useToast } from '@/hooks/use-toast' import { useTransition } from 'react' +import { useSession } from 'next-auth/react' +import { BiddingNoticeEditor } from '@/lib/bidding/bidding-notice-editor' +import { getBiddingNotice } from '@/lib/bidding/service' +import { Button } from '@/components/ui/button' +import { Card, CardContent } from '@/components/ui/card' +import { Badge } from '@/components/ui/badge' +import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from '@/components/ui/dialog' +import { FileText, Eye, CheckCircle2, AlertCircle } from 'lucide-react' interface BiddingDetailContentProps { bidding: Bidding @@ -27,12 +36,14 @@ export function BiddingDetailContent({ }: BiddingDetailContentProps) { const { toast } = useToast() const [isPending, startTransition] = useTransition() + const session = useSession() const [dialogStates, setDialogStates] = React.useState({ items: false, targetPrice: false, selectionReason: false, - award: false + award: false, + biddingNotice: false }) const [, setRefreshTrigger] = React.useState(0) @@ -42,14 +53,119 @@ export function BiddingDetailContent({ const [selectedVendorForDetails, setSelectedVendorForDetails] = React.useState(null) const [prItemsForDialog, setPrItemsForDialog] = React.useState([]) + // 입찰공고 관련 state + const [biddingNotice, setBiddingNotice] = React.useState(null) + const [isBiddingNoticeLoading, setIsBiddingNoticeLoading] = React.useState(false) + + // 최종제출 현황 관련 state + const [finalSubmissionStatus, setFinalSubmissionStatus] = React.useState<{ + allSubmitted: boolean + totalCompanies: number + submittedCompanies: number + }>({ allSubmitted: false, totalCompanies: 0, submittedCompanies: 0 }) + const [isPerformingBidOpening, setIsPerformingBidOpening] = React.useState(false) + const handleRefresh = React.useCallback(() => { setRefreshTrigger(prev => prev + 1) }, []) + // 입찰공고 로드 함수 + const loadBiddingNotice = React.useCallback(async () => { + if (!bidding.id) return + + setIsBiddingNoticeLoading(true) + try { + const notice = await getBiddingNotice(bidding.id) + setBiddingNotice(notice) + } catch (error) { + console.error('Failed to load bidding notice:', error) + toast({ + title: '오류', + description: '입찰공고문을 불러오는데 실패했습니다.', + variant: 'destructive', + }) + } finally { + setIsBiddingNoticeLoading(false) + } + }, [bidding.id, toast]) + const openDialog = React.useCallback((type: keyof typeof dialogStates) => { setDialogStates(prev => ({ ...prev, [type]: true })) }, []) + // 최종제출 현황 로드 함수 + const loadFinalSubmissionStatus = React.useCallback(async () => { + if (!bidding.id) return + + try { + const status = await checkAllVendorsFinalSubmitted(bidding.id) + setFinalSubmissionStatus(status) + } catch (error) { + console.error('Failed to load final submission status:', error) + } + }, [bidding.id]) + + // 개찰 핸들러 + const handlePerformBidOpening = async (isEarly: boolean = false) => { + if (!session.data?.user?.id) { + toast({ + title: '권한 없음', + description: '로그인이 필요합니다.', + variant: 'destructive', + }) + return + } + + if (!finalSubmissionStatus.allSubmitted) { + toast({ + title: '개찰 불가', + description: `모든 벤더가 최종 제출해야 개찰할 수 있습니다. (${finalSubmissionStatus.submittedCompanies}/${finalSubmissionStatus.totalCompanies})`, + variant: 'destructive', + }) + return + } + + const message = isEarly ? '조기개찰을 진행하시겠습니까?' : '개찰을 진행하시겠습니까?' + if (!window.confirm(message)) { + return + } + + setIsPerformingBidOpening(true) + try { + const result = await performBidOpening(bidding.id, session.data.user.id.toString(), isEarly) + + if (result.success) { + toast({ + title: '개찰 완료', + description: result.message, + }) + // 페이지 새로고침 + window.location.reload() + } else { + toast({ + title: '개찰 실패', + description: result.error, + variant: 'destructive', + }) + } + } catch (error) { + console.error('Failed to perform bid opening:', error) + toast({ + title: '오류', + description: '개찰에 실패했습니다.', + variant: 'destructive', + }) + } finally { + setIsPerformingBidOpening(false) + } + } + + // 컴포넌트 마운트 시 입찰공고 및 최종제출 현황 로드 + React.useEffect(() => { + loadBiddingNotice() + loadFinalSubmissionStatus() + }, [loadBiddingNotice, loadFinalSubmissionStatus]) + const closeDialog = React.useCallback((type: keyof typeof dialogStates) => { setDialogStates(prev => ({ ...prev, [type]: false })) }, []) @@ -73,8 +189,91 @@ export function BiddingDetailContent({ }) }, [bidding.id, toast]) + // 개찰 버튼 표시 여부 (입찰평가중 상태에서만) + const showBidOpeningButtons = bidding.status === 'evaluation_of_bidding' + return (
+ {/* 입찰공고 편집 버튼 */} +
+
+

입찰 상세

+

{bidding.title}

+
+ setDialogStates(prev => ({ ...prev, biddingNotice: open }))}> + + + + + + 입찰공고 편집 + +
+ setDialogStates(prev => ({ ...prev, biddingNotice: false }))} + /> +
+
+
+
+ + {/* 최종제출 현황 및 개찰 버튼 */} + {showBidOpeningButtons && ( + + +
+
+
+
+ {finalSubmissionStatus.allSubmitted ? ( + + ) : ( + + )} +

최종제출 현황

+
+
+ + 최종 제출: {finalSubmissionStatus.submittedCompanies}/{finalSubmissionStatus.totalCompanies}개 업체 + + {finalSubmissionStatus.allSubmitted ? ( + 모든 업체 제출 완료 + ) : ( + 제출 대기 중 + )} +
+
+
+ + {/* 개찰 버튼들 */} +
+ + +
+
+
+
+ )} + { - const status = row.original.status - const variant = status === 'selected' ? 'default' : - status === 'submitted' ? 'secondary' : - status === 'rejected' ? 'destructive' : 'outline' + const invitationStatus = row.original.invitationStatus + const variant = invitationStatus === 'bidding_submitted' ? 'default' : + invitationStatus === 'pre_quote_submitted' ? 'secondary' : + invitationStatus === 'bidding_declined' ? 'destructive' : 'outline' - const label = status === 'selected' ? '선정' : - status === 'submitted' ? '견적 제출' : - status === 'rejected' ? '거절' : '대기' + const label = invitationStatus === 'bidding_submitted' ? '응찰 완료' : + invitationStatus === 'pre_quote_submitted' ? '사전견적 제출' : + invitationStatus === 'bidding_declined' ? '응찰 거절' : + invitationStatus === 'pre_quote_declined' ? '사전견적 거절' : + invitationStatus === 'bidding_accepted' ? '응찰 참여' : + invitationStatus === 'pre_quote_accepted' ? '사전견적 참여' : + invitationStatus === 'pending' ? '대기' : + invitationStatus === 'pre_quote_sent' ? '사전견적 초대' : + invitationStatus === 'bidding_sent' ? '응찰 초대' : + invitationStatus || '알 수 없음' return {label} }, diff --git a/lib/bidding/detail/table/bidding-detail-vendor-create-dialog.tsx b/lib/bidding/detail/table/bidding-detail-vendor-create-dialog.tsx deleted file mode 100644 index d0f85b14..00000000 --- a/lib/bidding/detail/table/bidding-detail-vendor-create-dialog.tsx +++ /dev/null @@ -1,328 +0,0 @@ -'use client' - -import * as React from 'react' -import { Button } from '@/components/ui/button' -import { Input } from '@/components/ui/input' -import { Label } from '@/components/ui/label' -import { Textarea } from '@/components/ui/textarea' -import { Checkbox } from '@/components/ui/checkbox' -import { - Dialog, - DialogContent, - DialogDescription, - DialogFooter, - DialogHeader, - DialogTitle, -} from '@/components/ui/dialog' -import { - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue, -} from '@/components/ui/select' -import { - Command, - CommandEmpty, - CommandGroup, - CommandInput, - CommandItem, - CommandList, -} from '@/components/ui/command' -import { - Popover, - PopoverContent, - PopoverTrigger, -} from '@/components/ui/popover' -import { Check, ChevronsUpDown, Search, Loader2, X, Plus } from 'lucide-react' -import { cn } from '@/lib/utils' -import { createBiddingDetailVendor } from '@/lib/bidding/detail/service' -import { searchVendorsForBidding } from '@/lib/bidding/service' -import { useToast } from '@/hooks/use-toast' -import { useTransition } from 'react' -import { Badge } from '@/components/ui/badge' -import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card' - -interface BiddingDetailVendorCreateDialogProps { - biddingId: number - open: boolean - onOpenChange: (open: boolean) => void - onSuccess: () => void -} - -interface Vendor { - id: number - vendorName: string - vendorCode: string - status: string -} - -export function BiddingDetailVendorCreateDialog({ - biddingId, - open, - onOpenChange, - onSuccess -}: BiddingDetailVendorCreateDialogProps) { - const { toast } = useToast() - const [isPending, startTransition] = useTransition() - - // Vendor 검색 상태 - const [vendorList, setVendorList] = React.useState([]) - const [selectedVendors, setSelectedVendors] = React.useState([]) - const [vendorOpen, setVendorOpen] = React.useState(false) - - // 폼 상태 (간소화 - 필수 항목만) - const [formData, setFormData] = React.useState({ - awardRatio: 100, // 기본 100% - }) - - // 벤더 로드 - const loadVendors = React.useCallback(async () => { - try { - const result = await searchVendorsForBidding('', biddingId) // 빈 검색어로 모든 벤더 로드 - setVendorList(result || []) - } catch (error) { - console.error('Failed to load vendors:', error) - toast({ - title: '오류', - description: '벤더 목록을 불러오는데 실패했습니다.', - variant: 'destructive', - }) - setVendorList([]) - } - }, [biddingId]) - - React.useEffect(() => { - if (open) { - loadVendors() - } - }, [open, loadVendors]) - - // 초기화 - React.useEffect(() => { - if (!open) { - setSelectedVendors([]) - setFormData({ - awardRatio: 100, // 기본 100% - }) - } - }, [open]) - - // 벤더 추가 - const handleAddVendor = (vendor: Vendor) => { - if (!selectedVendors.find(v => v.id === vendor.id)) { - setSelectedVendors([...selectedVendors, vendor]) - } - setVendorOpen(false) - } - - // 벤더 제거 - const handleRemoveVendor = (vendorId: number) => { - setSelectedVendors(selectedVendors.filter(v => v.id !== vendorId)) - } - - // 이미 선택된 벤더인지 확인 - const isVendorSelected = (vendorId: number) => { - return selectedVendors.some(v => v.id === vendorId) - } - - const handleCreate = () => { - if (selectedVendors.length === 0) { - toast({ - title: '오류', - description: '업체를 선택해주세요.', - variant: 'destructive', - }) - return - } - - startTransition(async () => { - let successCount = 0 - let errorMessages: string[] = [] - - for (const vendor of selectedVendors) { - try { - const response = await createBiddingDetailVendor( - biddingId, - vendor.id - ) - - if (response.success) { - successCount++ - } else { - errorMessages.push(`${vendor.vendorName}: ${response.error}`) - } - } catch (error) { - errorMessages.push(`${vendor.vendorName}: 처리 중 오류가 발생했습니다.`) - } - } - - if (successCount > 0) { - toast({ - title: '성공', - description: `${successCount}개의 업체가 성공적으로 추가되었습니다.${errorMessages.length > 0 ? ` ${errorMessages.length}개는 실패했습니다.` : ''}`, - }) - onOpenChange(false) - resetForm() - onSuccess() - } - - if (errorMessages.length > 0 && successCount === 0) { - toast({ - title: '오류', - description: `업체 추가에 실패했습니다: ${errorMessages.join(', ')}`, - variant: 'destructive', - }) - } - }) - } - - const resetForm = () => { - setSelectedVendors([]) - setFormData({ - awardRatio: 100, // 기본 100% - }) - } - - return ( - - - {/* 헤더 */} - - 협력업체 추가 - - 입찰에 참여할 업체를 선택하세요. 여러 개 선택 가능합니다. - - - - {/* 메인 컨텐츠 */} -
-
- {/* 업체 선택 카드 */} - - - 업체 선택 - - 입찰에 참여할 협력업체를 선택하세요. - - - -
- {/* 업체 추가 버튼 */} - - - - - - - - - 검색 결과가 없습니다. - - {vendorList - .filter(vendor => !isVendorSelected(vendor.id)) - .map((vendor) => ( - handleAddVendor(vendor)} - > -
- - {vendor.vendorCode} - - {vendor.vendorName} -
-
- ))} -
-
-
-
-
- - {/* 선택된 업체 목록 */} - {selectedVendors.length > 0 && ( -
-
-

선택된 업체 ({selectedVendors.length}개)

-
-
- {selectedVendors.map((vendor, index) => ( -
-
- - {index + 1}. - - - {vendor.vendorCode} - - - {vendor.vendorName} - -
- -
- ))} -
-
- )} - - {selectedVendors.length === 0 && ( -
-

아직 선택된 업체가 없습니다.

-

위 버튼을 클릭하여 업체를 추가하세요.

-
- )} -
-
-
-
-
- - {/* 푸터 */} - - - - -
-
- ) -} diff --git a/lib/bidding/detail/table/bidding-detail-vendor-toolbar-actions.tsx b/lib/bidding/detail/table/bidding-detail-vendor-toolbar-actions.tsx index e3b5c288..4d987739 100644 --- a/lib/bidding/detail/table/bidding-detail-vendor-toolbar-actions.tsx +++ b/lib/bidding/detail/table/bidding-detail-vendor-toolbar-actions.tsx @@ -8,7 +8,7 @@ import { Plus, Send, RotateCcw, XCircle, Trophy, FileText, DollarSign } from "lu import { registerBidding, markAsDisposal, createRebidding } from "@/lib/bidding/detail/service" import { sendBiddingBasicContracts, getSelectedVendorsForBidding } from "@/lib/bidding/pre-quote/service" -import { BiddingDetailVendorCreateDialog } from "./bidding-detail-vendor-create-dialog" +import { BiddingDetailVendorCreateDialog } from "../../../../components/bidding/manage/bidding-detail-vendor-create-dialog" import { BiddingDocumentUploadDialog } from "./bidding-document-upload-dialog" import { BiddingVendorPricesDialog } from "./bidding-vendor-prices-dialog" import { Bidding } from "@/db/schema" @@ -189,13 +189,11 @@ export function BiddingDetailVendorToolbarActions({ variant="default" size="sm" onClick={handleRegister} - disabled={isPending || bidding.status === 'received_quotation'} + disabled={isPending} > + {/* 입찰등록 시점 재정의 필요*/} 입찰 등록 - {bidding.status === 'received_quotation' && ( - (사전견적 제출 완료) - )} +
+ ), + size: 200, + meta: { excelHeader: "입찰명" }, + }, + + // ░░░ 진행상태 ░░░ + { + accessorKey: "status", + header: ({ column }) => , + cell: ({ row }) => ( + + {biddingStatusLabels[row.original.status]} + + ), + size: 120, + meta: { excelHeader: "진행상태" }, + }, + + // ░░░ 계약구분 ░░░ + { + accessorKey: "contractType", + header: ({ column }) => , + cell: ({ row }) => ( + + {contractTypeLabels[row.original.contractType]} + + ), + size: 100, + meta: { excelHeader: "계약구분" }, + }, + + // ░░░ 내정가 ░░░ + { + accessorKey: "targetPrice", + header: ({ column }) => , + cell: ({ row }) => { + const price = row.original.targetPrice + const currency = row.original.currency || 'KRW' + + return ( +
+ {price ? formatCurrency(price, currency) : '-'} +
+ ) + }, + size: 120, + meta: { excelHeader: "내정가" }, + }, + + // ░░░ 통화 ░░░ + { + accessorKey: "currency", + header: ({ column }) => , + cell: ({ row }) => ( + {row.original.currency || 'KRW'} + ), + size: 60, + meta: { excelHeader: "통화" }, + }, + + // ░░░ 입찰등록일 ░░░ + { + accessorKey: "biddingRegistrationDate", + header: ({ column }) => , + cell: ({ row }) => ( + {formatDate(row.original.biddingRegistrationDate, "KR")} + ), + size: 100, + meta: { excelHeader: "입찰등록일" }, + }, + + // ░░░ 입찰담당자 ░░░ + { + accessorKey: "bidPicName", + header: ({ column }) => , + cell: ({ row }) => { + const bidPic = row.original.bidPicName + const supplyPic = row.original.supplyPicName + + const displayName = bidPic || supplyPic || "-" + return {displayName} + }, + size: 100, + meta: { excelHeader: "입찰담당자" }, + }, + + // ░░░ 유찰일 ░░░ + { + id: "disposalDate", + header: ({ column }) => , + cell: ({ row }) => ( +
+ + {formatDate(row.original.disposalDate, "KR")} +
+ ), + size: 100, + meta: { excelHeader: "유찰일" }, + }, + + // ░░░ 폐찰일 ░░░ + { + id: "disposalUpdatedAt", + header: ({ column }) => , + cell: ({ row }) => ( +
+ + {formatDate(row.original.disposalUpdatedAt, "KR")} +
+ ), + size: 100, + meta: { excelHeader: "폐찰일" }, + }, + + // ░░░ 폐찰수정자 ░░░ + { + id: "disposalUpdatedBy", + header: ({ column }) => , + cell: ({ row }) => ( + {row.original.disposalUpdatedBy || '-'} + ), + size: 100, + meta: { excelHeader: "폐찰수정자" }, + }, + + // ░░░ P/R번호 ░░░ + { + accessorKey: "prNumber", + header: ({ column }) => , + cell: ({ row }) => ( + {row.original.prNumber || '-'} + ), + size: 100, + meta: { excelHeader: "P/R번호" }, + }, + + // ░░░ 등록자 ░░░ + { + accessorKey: "createdBy", + header: ({ column }) => , + cell: ({ row }) => ( + {row.original.createdBy || '-'} + ), + size: 100, + meta: { excelHeader: "등록자" }, + }, + + // ░░░ 등록일시 ░░░ + { + accessorKey: "createdAt", + header: ({ column }) => , + cell: ({ row }) => ( + {formatDate(row.original.createdAt, "KR")} + ), + size: 100, + meta: { excelHeader: "등록일시" }, + }, + + // ═══════════════════════════════════════════════════════════════ + // 액션 + // ═══════════════════════════════════════════════════════════════ + { + id: "actions", + header: "액션", + cell: ({ row }) => ( + + + + + + setRowAction({ row, type: "view" })}> + + 상세보기 + + setRowAction({ row, type: "history" })}> + + 이력보기 + + + setRowAction({ row, type: "rebid" })}> + + 재입찰 + + + + ), + size: 50, + enableSorting: false, + enableHiding: false, + }, + ] +} diff --git a/lib/bidding/failure/biddings-failure-table.tsx b/lib/bidding/failure/biddings-failure-table.tsx new file mode 100644 index 00000000..901648d2 --- /dev/null +++ b/lib/bidding/failure/biddings-failure-table.tsx @@ -0,0 +1,223 @@ +"use client" + +import * as React from "react" +import { useRouter } from "next/navigation" +import { useSession } from "next-auth/react" +import type { + DataTableAdvancedFilterField, + DataTableFilterField, + DataTableRowAction, +} from "@/types/table" + +import { useDataTable } from "@/hooks/use-data-table" +import { DataTable } from "@/components/data-table/data-table" +import { DataTableAdvancedToolbar } from "@/components/data-table/data-table-advanced-toolbar" +import { getBiddingsFailureColumns } from "./biddings-failure-columns" +import { getBiddingsForFailure } from "@/lib/bidding/service" +import { + biddingStatusLabels, + contractTypeLabels, +} from "@/db/schema" +import { SpecificationMeetingDialog, PrDocumentsDialog } from "../list/bidding-detail-dialogs" + +type BiddingFailureItem = { + id: number + biddingNumber: string + originalBiddingNumber: string | null + title: string + status: string + contractType: string + prNumber: string | null + + // 가격 정보 + targetPrice: number | null + currency: string | null + + // 일정 정보 + biddingRegistrationDate: Date | null + submissionStartDate: Date | null + submissionEndDate: Date | null + + // 담당자 정보 + bidPicName: string | null + supplyPicName: string | null + + // 유찰 정보 + disposalDate: Date | null // 유찰일 + disposalUpdatedAt: Date | null // 폐찰수정일 + disposalUpdatedBy: string | null // 폐찰수정자 + + // 기타 정보 + createdBy: string | null + createdAt: Date | null + updatedAt: Date | null + updatedBy: string | null +} + +interface BiddingsFailureTableProps { + promises: Promise< + [ + Awaited> + ] + > +} + +export function BiddingsFailureTable({ promises }: BiddingsFailureTableProps) { + const [biddingsResult] = React.use(promises) + + // biddingsResult에서 data와 pageCount 추출 + const { data, pageCount } = biddingsResult + + const [isCompact, setIsCompact] = React.useState(false) + const [specMeetingDialogOpen, setSpecMeetingDialogOpen] = React.useState(false) + const [prDocumentsDialogOpen, setPrDocumentsDialogOpen] = React.useState(false) + const [selectedBidding, setSelectedBidding] = React.useState(null) + + const [rowAction, setRowAction] = React.useState | null>(null) + + const router = useRouter() + const { data: session } = useSession() + + const columns = React.useMemo( + () => getBiddingsFailureColumns({ setRowAction }), + [setRowAction] + ) + + // rowAction 변경 감지하여 해당 다이얼로그 열기 + React.useEffect(() => { + if (rowAction) { + setSelectedBidding(rowAction.row.original) + + switch (rowAction.type) { + case "view": + // 상세 페이지로 이동 + router.push(`/evcp/bid/${rowAction.row.original.id}`) + break + case "history": + // 이력보기 (추후 구현) + console.log('이력보기:', rowAction.row.original) + break + case "rebid": + // 재입찰 (추후 구현) + console.log('재입찰:', rowAction.row.original) + break + default: + break + } + } + }, [rowAction]) + + const filterFields: DataTableFilterField[] = [ + { + id: "biddingNumber", + label: "입찰번호", + type: "text", + placeholder: "입찰번호를 입력하세요", + }, + { + id: "prNumber", + label: "P/R번호", + type: "text", + placeholder: "P/R번호를 입력하세요", + }, + { + id: "title", + label: "입찰명", + type: "text", + placeholder: "입찰명을 입력하세요", + }, + ] + + const advancedFilterFields: DataTableAdvancedFilterField[] = [ + { id: "title", label: "입찰명", type: "text" }, + { id: "biddingNumber", label: "입찰번호", type: "text" }, + { id: "bidPicName", label: "입찰담당자", type: "text" }, + { + id: "status", + label: "진행상태", + type: "multi-select", + options: Object.entries(biddingStatusLabels).map(([value, label]) => ({ + label, + value, + })), + }, + { + id: "contractType", + label: "계약구분", + type: "select", + options: Object.entries(contractTypeLabels).map(([value, label]) => ({ + label, + value, + })), + }, + { id: "createdAt", label: "등록일", type: "date" }, + { id: "biddingRegistrationDate", label: "입찰등록일", type: "date" }, + { id: "disposalDate", label: "유찰일", type: "date" }, + { id: "disposalUpdatedAt", label: "폐찰일", type: "date" }, + ] + + const { table } = useDataTable({ + data, + columns, + pageCount, + filterFields, + enablePinning: true, + enableAdvancedFilter: true, + initialState: { + sorting: [{ id: "disposalDate", desc: true }], // 유찰일 기준 최신순 + columnPinning: { right: ["actions"] }, + }, + getRowId: (originalRow) => String(originalRow.id), + shallow: false, + clearOnDefault: true, + }) + + const handleCompactChange = React.useCallback((compact: boolean) => { + setIsCompact(compact) + }, []) + + const handleSpecMeetingDialogClose = React.useCallback(() => { + setSpecMeetingDialogOpen(false) + setRowAction(null) + setSelectedBidding(null) + }, []) + + const handlePrDocumentsDialogClose = React.useCallback(() => { + setPrDocumentsDialogOpen(false) + setRowAction(null) + setSelectedBidding(null) + }, []) + + return ( + <> + + + + + + {/* 사양설명회 다이얼로그 */} + + + {/* PR 문서 다이얼로그 */} + + + ) +} diff --git a/lib/bidding/list/bidding-detail-dialogs.tsx b/lib/bidding/list/bidding-detail-dialogs.tsx index 4fbca616..065000ce 100644 --- a/lib/bidding/list/bidding-detail-dialogs.tsx +++ b/lib/bidding/list/bidding-detail-dialogs.tsx @@ -9,58 +9,49 @@ import { DialogHeader, DialogTitle, } from "@/components/ui/dialog" -import { - Table, - TableBody, - TableCell, - TableHead, - TableHeader, - TableRow, -} from "@/components/ui/table" import { Button } from "@/components/ui/button" import { Badge } from "@/components/ui/badge" import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card" -import { Separator } from "@/components/ui/separator" import { ScrollArea } from "@/components/ui/scroll-area" +import { Textarea } from "@/components/ui/textarea" +import { Label } from "@/components/ui/label" +import { Input } from "@/components/ui/input" import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger, } from "@/components/ui/tooltip" -import { - CalendarIcon, - ClockIcon, +import { + CalendarIcon, + ClockIcon, MapPinIcon, FileTextIcon, - DownloadIcon, - EyeIcon, - PackageIcon, - HashIcon, - DollarSignIcon, - WeightIcon, - ExternalLinkIcon + ExternalLinkIcon, + FileXIcon, + UploadIcon } from "lucide-react" -import { toast } from "sonner" import { BiddingListItem } from "@/db/schema" import { downloadFile, formatFileSize, getFileInfo } from "@/lib/file-download" -import { getPRDetailsAction, getSpecificationMeetingDetailsAction } from "../service" +import { getSpecificationMeetingDetailsAction } from "../service" +import { bidClosureAction } from "../actions" import { formatDate } from "@/lib/utils" +import { toast } from "sonner" // 타입 정의 interface SpecificationMeetingDetails { id: number; biddingId: number; meetingDate: string; - meetingTime: string | null; - location: string | null; - address: string | null; - contactPerson: string | null; - contactPhone: string | null; - contactEmail: string | null; - agenda: string | null; - materials: string | null; - notes: string | null; + meetingTime?: string | null; + location?: string | null; + address?: string | null; + contactPerson?: string | null; + contactPhone?: string | null; + contactEmail?: string | null; + agenda?: string | null; + materials?: string | null; + notes?: string | null; isRequired: boolean; createdAt: string; updatedAt: string; @@ -70,60 +61,13 @@ interface SpecificationMeetingDetails { originalFileName: string; fileSize: number; filePath: string; - title: string | null; + title?: string | null; uploadedAt: string; - uploadedBy: string | null; - }>; -} - -interface PRDetails { - documents: Array<{ - id: number; - documentName: string; - fileName: string; - originalFileName: string; - fileSize: number; - filePath: string; - registeredAt: string; - registeredBy: string | null; - version: string | null; - description: string | null; - createdAt: string; - updatedAt: string; - }>; - items: Array<{ - id: number; - itemNumber: string; - itemInfo: string | null; - quantity: number | null; - quantityUnit: string | null; - requestedDeliveryDate: string | null; - prNumber: string | null; - annualUnitPrice: number | null; - currency: string | null; - totalWeight: number | null; - weightUnit: string | null; - materialDescription: string | null; - hasSpecDocument: boolean; - createdAt: string; - updatedAt: string; - specDocuments: Array<{ - id: number; - fileName: string; - originalFileName: string; - fileSize: number; - filePath: string; - uploadedAt: string; - title: string | null; - }>; + uploadedBy?: string | null; }>; } -interface ActionResult { - success: boolean; - data?: T; - error?: string; -} +// PR 관련 타입과 컴포넌트는 bidding-pr-documents-dialog.tsx로 이동됨 // 파일 다운로드 훅 const useFileDownload = () => { @@ -212,52 +156,6 @@ const FileDownloadLink: React.FC = ({ ); }; -// 파일 다운로드 버튼 컴포넌트 (간소화된 버전) -interface FileDownloadButtonProps { - filePath: string; - fileName: string; - fileSize?: number; - title?: string | null; - variant?: "download" | "preview"; - size?: "sm" | "default" | "lg"; -} - -const FileDownloadButton: React.FC = ({ - filePath, - fileName, - fileSize, - title, - variant = "download", - size = "sm" -}) => { - const { handleDownload, downloadingFiles } = useFileDownload(); - const fileInfo = getFileInfo(fileName); - const fileKey = `${filePath}_${fileName}`; - const isDownloading = downloadingFiles.has(fileKey); - - const Icon = variant === "preview" && fileInfo.canPreview ? EyeIcon : DownloadIcon; - - return ( - - ); -}; - // 사양설명회 다이얼로그 interface SpecificationMeetingDialogProps { open: boolean; @@ -458,285 +356,131 @@ export function SpecificationMeetingDialog({ ); } -// PR 문서 다이얼로그 -interface PrDocumentsDialogProps { - open: boolean; - onOpenChange: (open: boolean) => void; - bidding: BiddingListItem | null; -} - -export function PrDocumentsDialog({ - open, - onOpenChange, - bidding -}: PrDocumentsDialogProps) { - const [data, setData] = useState(null); - const [loading, setLoading] = useState(false); - const [error, setError] = useState(null); - - useEffect(() => { - if (open && bidding) { - fetchPRData(); - } - }, [open, bidding]); - - const fetchPRData = async () => { - if (!bidding) return; - - setLoading(true); - setError(null); - - try { - const result = await getPRDetailsAction(bidding.id); - - if (result.success && result.data) { - setData(result.data); - } else { - setError(result.error || "PR 문서 정보를 불러올 수 없습니다."); - } - } catch (err) { - setError("데이터 로딩 중 오류가 발생했습니다."); - console.error("Failed to fetch PR data:", err); - } finally { - setLoading(false); - } - }; - - const formatCurrency = (amount: number | null, currency: string | null) => { - if (amount === null) return "-"; - return `${amount.toLocaleString()} ${currency || ""}`; - }; - - const formatWeight = (weight: number | null, unit: string | null) => { - if (weight === null) return "-"; - return `${weight.toLocaleString()} ${unit || ""}`; - }; +// PR 문서 다이얼로그는 bidding-pr-documents-dialog.tsx로 이동됨 +// import { PrDocumentsDialog } from './bidding-pr-documents-dialog'로 사용하세요 - return ( - - - - - - PR 문서 - - - {bidding?.title}의 PR 문서 및 아이템 정보입니다. - - - - - {loading ? ( -
-
-
-

로딩 중...

-
-
- ) : error ? ( -
-
-

{error}

- -
-
- ) : data ? ( -
- {/* PR 문서 목록 */} - {data.documents.length > 0 && ( - - - - - PR 문서 ({data.documents.length}개) - - - - - - - 문서명 - 파일명 - 버전 - 크기 - 등록일 - 등록자 - 다운로드 - - - - {data.documents.map((doc) => ( - - - {doc.documentName} - {doc.description && ( -
- {doc.description} -
- )} -
- - - - - {doc.version ? ( - {doc.version} - ) : "-"} - - {formatFileSize(doc.fileSize)} - - {new Date(doc.registeredAt).toLocaleDateString('ko-KR')} - - {doc.registeredBy || "-"} - - - -
- ))} -
-
-
-
- )} +// 폐찰하기 다이얼로그 +interface BidClosureDialogProps { + open: boolean; + onOpenChange: (open: boolean) => void; + bidding: BiddingListItem | null; + userId: string; +} - {/* PR 아이템 테이블 */} - {data.items.length > 0 && ( - - - - - PR 아이템 ({data.items.length}개) - - - - - - - 아이템 번호 - PR 번호 - 아이템 정보 - 수량 - 단가 - 중량 - 요청 납기 - 스펙 문서 - - - - {data.items.map((item) => ( - - - {item.itemNumber} - - - {item.prNumber || "-"} - - -
- {item.itemInfo && ( -
{item.itemInfo}
- )} - {item.materialDescription && ( -
- {item.materialDescription} -
- )} -
-
- -
- - - {item.quantity ? `${item.quantity.toLocaleString()} ${item.quantityUnit || ""}` : "-"} - -
-
- -
- - - {formatCurrency(item.annualUnitPrice, item.currency)} - -
-
- -
- - - {formatWeight(item.totalWeight, item.weightUnit)} - -
-
- - {item.requestedDeliveryDate ? ( -
- - - {new Date(item.requestedDeliveryDate).toLocaleDateString('ko-KR')} - -
- ) : "-"} -
- -
-
- - {item.hasSpecDocument ? "있음" : "없음"} - - {item.specDocuments.length > 0 && ( - - ({item.specDocuments.length}개) - - )} -
- {item.specDocuments.length > 0 && ( -
- {item.specDocuments.map((doc, index) => ( -
- -
- ))} -
- )} -
-
-
- ))} -
-
-
-
- )} +export function BidClosureDialog({ + open, + onOpenChange, + bidding, + userId +}: BidClosureDialogProps) { + const [description, setDescription] = useState('') + const [files, setFiles] = useState([]) + const [isSubmitting, setIsSubmitting] = useState(false) + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault() + + if (!bidding || !description.trim()) { + toast.error('폐찰 사유를 입력해주세요.') + return + } + + setIsSubmitting(true) + + try { + const result = await bidClosureAction(bidding.id, { + description: description.trim(), + files + }, userId) + + if (result.success) { + toast.success(result.message) + onOpenChange(false) + // 페이지 새로고침 또는 상태 업데이트 + window.location.reload() + } else { + toast.error(result.error || '폐찰 처리 중 오류가 발생했습니다.') + } + } catch (error) { + toast.error('폐찰 처리 중 오류가 발생했습니다.') + } finally { + setIsSubmitting(false) + } + } + + const handleFileChange = (e: React.ChangeEvent) => { + if (e.target.files) { + setFiles(Array.from(e.target.files)) + } + } + + if (!bidding) return null + + return ( + + + + + + 폐찰하기 + + + {bidding.title} ({bidding.biddingNumber})를 폐찰합니다. + + + +
+
+ +