summaryrefslogtreecommitdiff
path: root/lib/bidding
diff options
context:
space:
mode:
Diffstat (limited to 'lib/bidding')
-rw-r--r--lib/bidding/actions.ts1065
-rw-r--r--lib/bidding/bidding-notice-editor.tsx99
-rw-r--r--lib/bidding/bidding-notice-template-manager.tsx63
-rw-r--r--lib/bidding/detail/bidding-actions.ts227
-rw-r--r--lib/bidding/detail/service.ts160
-rw-r--r--lib/bidding/detail/table/bidding-detail-content.tsx201
-rw-r--r--lib/bidding/detail/table/bidding-detail-vendor-columns.tsx23
-rw-r--r--lib/bidding/detail/table/bidding-detail-vendor-create-dialog.tsx328
-rw-r--r--lib/bidding/detail/table/bidding-detail-vendor-toolbar-actions.tsx8
-rw-r--r--lib/bidding/detail/table/bidding-invitation-dialog.tsx718
-rw-r--r--lib/bidding/failure/biddings-failure-columns.tsx320
-rw-r--r--lib/bidding/failure/biddings-failure-table.tsx223
-rw-r--r--lib/bidding/list/bidding-detail-dialogs.tsx554
-rw-r--r--lib/bidding/list/bidding-pr-documents-dialog.tsx405
-rw-r--r--lib/bidding/list/biddings-table-columns.tsx649
-rw-r--r--lib/bidding/list/biddings-table-toolbar-actions.tsx64
-rw-r--r--lib/bidding/list/biddings-table.tsx30
-rw-r--r--lib/bidding/list/create-bidding-dialog.tsx4230
-rw-r--r--lib/bidding/pre-quote/service.ts3145
-rw-r--r--lib/bidding/pre-quote/table/bidding-pre-quote-attachments-dialog.tsx224
-rw-r--r--lib/bidding/pre-quote/table/bidding-pre-quote-content.tsx51
-rw-r--r--lib/bidding/pre-quote/table/bidding-pre-quote-invitation-dialog.tsx770
-rw-r--r--lib/bidding/pre-quote/table/bidding-pre-quote-item-details-dialog.tsx125
-rw-r--r--lib/bidding/pre-quote/table/bidding-pre-quote-selection-dialog.tsx157
-rw-r--r--lib/bidding/pre-quote/table/bidding-pre-quote-vendor-columns.tsx398
-rw-r--r--lib/bidding/pre-quote/table/bidding-pre-quote-vendor-create-dialog.tsx311
-rw-r--r--lib/bidding/pre-quote/table/bidding-pre-quote-vendor-edit-dialog.tsx200
-rw-r--r--lib/bidding/pre-quote/table/bidding-pre-quote-vendor-table.tsx257
-rw-r--r--lib/bidding/pre-quote/table/bidding-pre-quote-vendor-toolbar-actions.tsx130
-rw-r--r--lib/bidding/receive/biddings-receive-columns.tsx360
-rw-r--r--lib/bidding/receive/biddings-receive-table.tsx211
-rw-r--r--lib/bidding/selection/biddings-selection-columns.tsx289
-rw-r--r--lib/bidding/selection/biddings-selection-table.tsx218
-rw-r--r--lib/bidding/service.ts2841
-rw-r--r--lib/bidding/validation.ts170
-rw-r--r--lib/bidding/vendor/components/pr-items-pricing-table.tsx27
-rw-r--r--lib/bidding/vendor/partners-bidding-detail.tsx904
-rw-r--r--lib/bidding/vendor/partners-bidding-list-columns.tsx104
-rw-r--r--lib/bidding/vendor/partners-bidding-pre-quote.tsx1413
39 files changed, 11599 insertions, 10073 deletions
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<string> {
+ 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 (
<div className="space-y-6">
@@ -117,13 +167,13 @@ export function BiddingNoticeEditor({ initialData }: BiddingNoticeEditorProps) {
)}
</Button>
- <Button
+ {/* <Button
variant="outline"
onClick={handleReset}
disabled={isPending}
>
기본 템플릿으로 초기화
- </Button>
+ </Button> */}
{initialData && (
<div className="ml-auto text-sm text-muted-foreground">
@@ -131,15 +181,6 @@ export function BiddingNoticeEditor({ initialData }: BiddingNoticeEditorProps) {
</div>
)}
</div>
-
- {/* 미리보기 힌트 */}
- <div className="bg-muted/50 p-4 rounded-lg">
- <p className="text-sm text-muted-foreground">
- <strong>💡 사용 팁:</strong>
- 이 템플릿은 실제 입찰 공고 작성 시 기본값으로 사용됩니다.
- 회사 정보, 표준 조건, 서식 등을 미리 작성해두면 편리합니다.
- </p>
- </div>
</div>
)
}
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<string, BiddingNoticeTemplate>
+}
+
+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 (
+ <Tabs value={activeTab} onValueChange={setActiveTab} className="w-full">
+ <TabsList className="grid w-full grid-cols-3">
+ {templateTypes.map(({ key, label }) => (
+ <TabsTrigger key={key} value={key}>
+ {label}
+ </TabsTrigger>
+ ))}
+ </TabsList>
+
+ {templateTypes.map(({ key, label }) => (
+ <TabsContent key={key} value={key}>
+ <Card>
+ <CardHeader>
+ <CardTitle>{label} 입찰공고문 템플릿</CardTitle>
+ <CardDescription>
+ {label} 타입의 입찰공고문 템플릿을 작성하고 관리할 수 있습니다.
+ 이 템플릿은 실제 입찰 공고 작성 시 기본 양식으로 사용됩니다.
+ </CardDescription>
+ </CardHeader>
+ <CardContent>
+ <BiddingNoticeEditor
+ initialData={templates[key]}
+ templateType={key}
+ onTemplateUpdate={(template) => handleTemplateUpdate(key, template)}
+ />
+ </CardContent>
+ </Card>
+ </TabsContent>
+ ))}
+ </Tabs>
+ )
+}
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<string> {
+ 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<QuotationV
vendorId: biddingCompanies.companyId,
vendorName: vendors.vendorName,
vendorCode: vendors.vendorCode,
+ vendorEmail: vendors.email, // 벤더의 기본 이메일
contactPerson: biddingCompanies.contactPerson,
contactEmail: biddingCompanies.contactEmail,
contactPhone: biddingCompanies.contactPhone,
@@ -251,12 +253,7 @@ export async function getQuotationVendors(biddingId: number): Promise<QuotationV
// awardRatio: sql<number>`CASE WHEN ${biddingCompanies.isWinner} THEN 100 ELSE 0 END`,
awardRatio: biddingCompanies.awardRatio,
isBiddingParticipated: biddingCompanies.isBiddingParticipated,
- status: sql<string>`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<QuotationV
vendorId: vendor.vendorId,
vendorName: vendor.vendorName || `Vendor ${vendor.vendorId}`,
vendorCode: vendor.vendorCode || '',
+ vendorEmail: vendor.vendorEmail || '', // 벤더의 기본 이메일
contactPerson: vendor.contactPerson || '',
contactEmail: vendor.contactEmail || '',
contactPhone: vendor.contactPhone || '',
@@ -281,7 +279,7 @@ export async function getQuotationVendors(biddingId: number): Promise<QuotationV
isWinner: vendor.isWinner,
awardRatio: vendor.awardRatio ? Number(vendor.awardRatio) : null,
isBiddingParticipated: vendor.isBiddingParticipated,
- status: vendor.status as 'pending' | 'submitted' | 'selected' | 'rejected',
+ invitationStatus: vendor.invitationStatus,
documents: [], // 빈 배열로 초기화
}))
} catch (error) {
@@ -622,7 +620,8 @@ export async function updateBiddingDetailVendor(
// 본입찰용 업체 추가
export async function createBiddingDetailVendor(
biddingId: number,
- vendorId: number
+ vendorId: number,
+ isPriceAdjustmentApplicableQuestion?: boolean
) {
try {
const result = await db.transaction(async (tx) => {
@@ -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<Part
// biddings 정보
biddingId: biddings.id,
biddingNumber: biddings.biddingNumber,
+ originalBiddingNumber: biddings.originalBiddingNumber, // 원입찰번호
revision: biddings.revision,
projectName: biddings.projectName,
itemName: biddings.itemName,
@@ -1584,9 +1584,12 @@ export async function getBiddingListForPartners(companyId: number): Promise<Part
submissionStartDate: biddings.submissionStartDate,
submissionEndDate: biddings.submissionEndDate,
status: biddings.status,
- managerName: biddings.managerName,
- managerEmail: biddings.managerEmail,
- managerPhone: biddings.managerPhone,
+ // 기존 담당자 필드 (하위호환성 유지)
+
+ // 입찰담당자
+ bidPicName: biddings.bidPicName,
+ // 조달담당자
+ supplyPicName: biddings.supplyPicName,
currency: biddings.currency,
budget: biddings.budget,
isUrgent: biddings.isUrgent,
@@ -1635,7 +1638,6 @@ export async function getBiddingDetailsForPartners(biddingId: number, companyId:
itemName: biddings.itemName,
title: biddings.title,
description: biddings.description,
- content: biddings.content,
// 계약 정보
contractType: biddings.contractType,
@@ -1659,15 +1661,15 @@ export async function getBiddingDetailsForPartners(biddingId: number, companyId:
// 상태 및 담당자
status: biddings.status,
isUrgent: biddings.isUrgent,
- managerName: biddings.managerName,
- managerEmail: biddings.managerEmail,
- managerPhone: biddings.managerPhone,
+ bidPicName: biddings.bidPicName,
+ supplyPicName: biddings.supplyPicName,
// 협력업체 특정 정보
biddingCompanyId: biddingCompanies.id,
invitationStatus: biddingCompanies.invitationStatus,
finalQuoteAmount: biddingCompanies.finalQuoteAmount,
finalQuoteSubmittedAt: biddingCompanies.finalQuoteSubmittedAt,
+ isFinalSubmission: biddingCompanies.isFinalSubmission,
isWinner: biddingCompanies.isWinner,
isAttendingMeeting: biddingCompanies.isAttendingMeeting,
isPreQuoteSelected: biddingCompanies.isPreQuoteSelected,
@@ -1718,6 +1720,7 @@ export async function submitPartnerResponse(
sparePartResponse?: string
additionalProposals?: string
finalQuoteAmount?: number
+ isFinalSubmission?: boolean // 최종제출 여부 추가
prItemQuotations?: Array<{
prItemId: number
bidUnitPrice: number
@@ -1851,7 +1854,15 @@ export async function submitPartnerResponse(
if (response.finalQuoteAmount !== undefined) {
companyUpdateData.finalQuoteAmount = response.finalQuoteAmount
companyUpdateData.finalQuoteSubmittedAt = new Date()
- companyUpdateData.invitationStatus = 'submitted'
+
+ // 최종제출 여부에 따라 상태 및 플래그 설정
+ if (response.isFinalSubmission) {
+ companyUpdateData.isFinalSubmission = true
+ companyUpdateData.invitationStatus = 'bidding_submitted' // 응찰 완료
+ } else {
+ companyUpdateData.isFinalSubmission = false
+ // 임시저장: invitationStatus는 변경하지 않음 (bidding_accepted 유지)
+ }
}
await tx
@@ -1868,8 +1879,8 @@ export async function submitPartnerResponse(
const biddingId = biddingCompanyInfo[0]?.biddingId
- // 응찰 제출 시 입찰 상태를 평가중으로 변경 (bidding_opened 상태에서만)
- if (biddingId && response.finalQuoteAmount !== undefined) {
+ // 최종제출인 경우, 입찰 상태를 평가중으로 변경 (bidding_opened 상태에서만)
+ if (biddingId && response.finalQuoteAmount !== undefined && response.isFinalSubmission) {
await tx
.update(biddings)
.set({
@@ -2023,14 +2034,15 @@ export async function updatePartnerAttendance(
})
.where(eq(biddingCompanies.id, biddingCompanyId))
- // 참석하는 경우, 사양설명회 담당자에게 이메일 발송을 위한 정보 반환
+ // 참석하는 경우, 사양설명회 담당자(contactEmail)에 이메일 발송을 위한 정보 반환
if (attendanceData.isAttending) {
+ // 입찰 + 사양설명회 + 업체 정보 불러오기
const biddingInfo = await tx
.select({
biddingId: biddingCompanies.biddingId,
companyId: biddingCompanies.companyId,
- managerEmail: biddings.managerEmail,
- managerName: biddings.managerName,
+ bidPicName: biddings.bidPicName,
+ supplyPicName: biddings.supplyPicName,
title: biddings.title,
biddingNumber: biddings.biddingNumber,
})
@@ -2040,7 +2052,7 @@ export async function updatePartnerAttendance(
.limit(1)
if (biddingInfo.length > 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<QuotationVendor | null>(null)
const [prItemsForDialog, setPrItemsForDialog] = React.useState<any[]>([])
+ // 입찰공고 관련 state
+ const [biddingNotice, setBiddingNotice] = React.useState<any>(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 (
<div className="space-y-6">
+ {/* 입찰공고 편집 버튼 */}
+ <div className="flex justify-between items-center">
+ <div>
+ <h2 className="text-2xl font-bold">입찰 상세</h2>
+ <p className="text-muted-foreground">{bidding.title}</p>
+ </div>
+ <Dialog open={dialogStates.biddingNotice} onOpenChange={(open) => setDialogStates(prev => ({ ...prev, biddingNotice: open }))}>
+ <DialogTrigger asChild>
+ <Button variant="outline" className="gap-2">
+ <FileText className="h-4 w-4" />
+ 입찰공고 편집
+ </Button>
+ </DialogTrigger>
+ <DialogContent className="max-w-4xl max-h-[80vh] overflow-hidden">
+ <DialogHeader>
+ <DialogTitle>입찰공고 편집</DialogTitle>
+ </DialogHeader>
+ <div className="max-h-[60vh] overflow-y-auto">
+ <BiddingNoticeEditor
+ initialData={biddingNotice}
+ biddingId={bidding.id}
+ onSaveSuccess={() => setDialogStates(prev => ({ ...prev, biddingNotice: false }))}
+ />
+ </div>
+ </DialogContent>
+ </Dialog>
+ </div>
+
+ {/* 최종제출 현황 및 개찰 버튼 */}
+ {showBidOpeningButtons && (
+ <Card>
+ <CardContent className="pt-6">
+ <div className="flex items-center justify-between">
+ <div className="flex items-center gap-4">
+ <div>
+ <div className="flex items-center gap-2 mb-1">
+ {finalSubmissionStatus.allSubmitted ? (
+ <CheckCircle2 className="h-5 w-5 text-green-600" />
+ ) : (
+ <AlertCircle className="h-5 w-5 text-yellow-600" />
+ )}
+ <h3 className="text-lg font-semibold">최종제출 현황</h3>
+ </div>
+ <div className="flex items-center gap-2">
+ <span className="text-sm text-muted-foreground">
+ 최종 제출: {finalSubmissionStatus.submittedCompanies}/{finalSubmissionStatus.totalCompanies}개 업체
+ </span>
+ {finalSubmissionStatus.allSubmitted ? (
+ <Badge variant="default">모든 업체 제출 완료</Badge>
+ ) : (
+ <Badge variant="secondary">제출 대기 중</Badge>
+ )}
+ </div>
+ </div>
+ </div>
+
+ {/* 개찰 버튼들 */}
+ <div className="flex gap-2">
+ <Button
+ onClick={() => handlePerformBidOpening(false)}
+ disabled={!finalSubmissionStatus.allSubmitted || isPerformingBidOpening}
+ variant="default"
+ >
+ <Eye className="h-4 w-4 mr-2" />
+ {isPerformingBidOpening ? '처리 중...' : '개찰'}
+ </Button>
+ <Button
+ onClick={() => handlePerformBidOpening(true)}
+ disabled={!finalSubmissionStatus.allSubmitted || isPerformingBidOpening}
+ variant="outline"
+ >
+ <Eye className="h-4 w-4 mr-2" />
+ {isPerformingBidOpening ? '처리 중...' : '조기개찰'}
+ </Button>
+ </div>
+ </div>
+ </CardContent>
+ </Card>
+ )}
+
<BiddingDetailVendorTableContent
biddingId={bidding.id}
bidding={bidding}
diff --git a/lib/bidding/detail/table/bidding-detail-vendor-columns.tsx b/lib/bidding/detail/table/bidding-detail-vendor-columns.tsx
index 1de7c768..10085e55 100644
--- a/lib/bidding/detail/table/bidding-detail-vendor-columns.tsx
+++ b/lib/bidding/detail/table/bidding-detail-vendor-columns.tsx
@@ -130,17 +130,24 @@ export function getBiddingDetailVendorColumns({
},
},
{
- accessorKey: 'status',
+ accessorKey: 'invitationStatus',
header: '상태',
cell: ({ row }) => {
- 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 <Badge variant={variant}>{label}</Badge>
},
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<Vendor[]>([])
- const [selectedVendors, setSelectedVendors] = React.useState<Vendor[]>([])
- 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 (
- <Dialog open={open} onOpenChange={onOpenChange}>
- <DialogContent className="max-w-4xl max-h-[90vh] p-0 flex flex-col">
- {/* 헤더 */}
- <DialogHeader className="p-6 pb-0">
- <DialogTitle>협력업체 추가</DialogTitle>
- <DialogDescription>
- 입찰에 참여할 업체를 선택하세요. 여러 개 선택 가능합니다.
- </DialogDescription>
- </DialogHeader>
-
- {/* 메인 컨텐츠 */}
- <div className="flex-1 px-6 py-4 overflow-y-auto">
- <div className="space-y-6">
- {/* 업체 선택 카드 */}
- <Card>
- <CardHeader>
- <CardTitle className="text-lg">업체 선택</CardTitle>
- <CardDescription>
- 입찰에 참여할 협력업체를 선택하세요.
- </CardDescription>
- </CardHeader>
- <CardContent>
- <div className="space-y-4">
- {/* 업체 추가 버튼 */}
- <Popover open={vendorOpen} onOpenChange={setVendorOpen}>
- <PopoverTrigger asChild>
- <Button
- variant="outline"
- role="combobox"
- aria-expanded={vendorOpen}
- className="w-full justify-between"
- disabled={vendorList.length === 0}
- >
- <span className="flex items-center gap-2">
- <Plus className="h-4 w-4" />
- 업체 선택하기
- </span>
- <ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
- </Button>
- </PopoverTrigger>
- <PopoverContent className="w-[500px] p-0" align="start">
- <Command>
- <CommandInput placeholder="업체명 또는 코드로 검색..." />
- <CommandList>
- <CommandEmpty>검색 결과가 없습니다.</CommandEmpty>
- <CommandGroup>
- {vendorList
- .filter(vendor => !isVendorSelected(vendor.id))
- .map((vendor) => (
- <CommandItem
- key={vendor.id}
- value={`${vendor.vendorCode} ${vendor.vendorName}`}
- onSelect={() => handleAddVendor(vendor)}
- >
- <div className="flex items-center gap-2 w-full">
- <Badge variant="outline" className="shrink-0">
- {vendor.vendorCode}
- </Badge>
- <span className="truncate">{vendor.vendorName}</span>
- </div>
- </CommandItem>
- ))}
- </CommandGroup>
- </CommandList>
- </Command>
- </PopoverContent>
- </Popover>
-
- {/* 선택된 업체 목록 */}
- {selectedVendors.length > 0 && (
- <div className="space-y-2">
- <div className="flex items-center justify-between">
- <h4 className="text-sm font-medium">선택된 업체 ({selectedVendors.length}개)</h4>
- </div>
- <div className="space-y-2">
- {selectedVendors.map((vendor, index) => (
- <div
- key={vendor.id}
- className="flex items-center justify-between p-3 rounded-lg bg-secondary/50"
- >
- <div className="flex items-center gap-3">
- <span className="text-sm text-muted-foreground">
- {index + 1}.
- </span>
- <Badge variant="outline">
- {vendor.vendorCode}
- </Badge>
- <span className="text-sm font-medium">
- {vendor.vendorName}
- </span>
- </div>
- <Button
- variant="ghost"
- size="sm"
- onClick={() => handleRemoveVendor(vendor.id)}
- className="h-8 w-8 p-0"
- >
- <X className="h-4 w-4" />
- </Button>
- </div>
- ))}
- </div>
- </div>
- )}
-
- {selectedVendors.length === 0 && (
- <div className="text-center py-8 text-muted-foreground">
- <p className="text-sm">아직 선택된 업체가 없습니다.</p>
- <p className="text-xs mt-1">위 버튼을 클릭하여 업체를 추가하세요.</p>
- </div>
- )}
- </div>
- </CardContent>
- </Card>
- </div>
- </div>
-
- {/* 푸터 */}
- <DialogFooter className="p-6 pt-0 border-t">
- <Button
- variant="outline"
- onClick={() => onOpenChange(false)}
- disabled={isPending}
- >
- 취소
- </Button>
- <Button
- onClick={handleCreate}
- disabled={isPending || selectedVendors.length === 0}
- >
- {isPending && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
- {selectedVendors.length > 0
- ? `${selectedVendors.length}개 업체 추가`
- : '업체 추가'
- }
- </Button>
- </DialogFooter>
- </DialogContent>
- </Dialog>
- )
-}
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}
>
+ {/* 입찰등록 시점 재정의 필요*/}
<Send className="mr-2 h-4 w-4" />
입찰 등록
- {bidding.status === 'received_quotation' && (
- <span className="text-xs text-muted-foreground ml-2">(사전견적 제출 완료)</span>
- )}
</Button>
<Button
variant="destructive"
diff --git a/lib/bidding/detail/table/bidding-invitation-dialog.tsx b/lib/bidding/detail/table/bidding-invitation-dialog.tsx
index cd79850a..ffb1fcb3 100644
--- a/lib/bidding/detail/table/bidding-invitation-dialog.tsx
+++ b/lib/bidding/detail/table/bidding-invitation-dialog.tsx
@@ -14,7 +14,6 @@ import {
DialogTitle,
} from '@/components/ui/dialog'
import { Badge } from '@/components/ui/badge'
-import { Separator } from '@/components/ui/separator'
import { Progress } from '@/components/ui/progress'
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
@@ -22,24 +21,45 @@ import { cn } from '@/lib/utils'
import {
Mail,
Building2,
- Calendar,
FileText,
CheckCircle,
Info,
RefreshCw,
+ X,
+ ChevronDown,
Plus,
- X
+ UserPlus,
+ Users
} from 'lucide-react'
-import { sendBiddingBasicContracts, getSelectedVendorsForBidding, getExistingBasicContractsForBidding } from '../../pre-quote/service'
+import { getExistingBasicContractsForBidding } from '../../pre-quote/service'
import { getActiveContractTemplates } from '../../service'
+import { getVendorContacts } from '@/lib/vendors/service'
import { useToast } from '@/hooks/use-toast'
import { useTransition } from 'react'
+import { SelectTrigger } from '@/components/ui/select'
+import { SelectValue } from '@/components/ui/select'
+import { SelectContent } from '@/components/ui/select'
+import { SelectItem } from '@/components/ui/select'
+import { Select } from '@/components/ui/select'
+import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
+import { Separator } from '@/components/ui/separator'
+
+
+interface VendorContact {
+ id: number
+ contactName: string
+ contactEmail: string
+ contactPhone?: string | null
+ contactPosition?: string | null
+ contactDepartment?: string | null
+}
interface VendorContractRequirement {
vendorId: number
vendorName: string
vendorCode?: string
vendorCountry?: string
+ vendorEmail?: string // 벤더의 기본 이메일 (vendors.email)
contactPerson?: string
contactEmail?: string
ndaYn?: boolean
@@ -50,6 +70,20 @@ interface VendorContractRequirement {
biddingId: number
}
+interface CustomEmail {
+ id: string
+ email: string
+ name?: string
+}
+
+interface VendorWithContactInfo extends VendorContractRequirement {
+ contacts: VendorContact[]
+ selectedMainEmail: string
+ additionalEmails: string[]
+ customEmails: CustomEmail[]
+ hasExistingContracts: boolean
+}
+
interface BasicContractTemplate {
id: number
templateName: string
@@ -74,25 +108,8 @@ interface BiddingInvitationDialogProps {
vendors: VendorContractRequirement[]
biddingId: number
biddingTitle: string
- projectName?: string
onSend: (data: {
- vendors: Array<{
- vendorId: number
- vendorName: string
- vendorCode?: string
- vendorCountry?: string
- selectedMainEmail: string
- additionalEmails: string[]
- contractRequirements: {
- ndaYn: boolean
- generalGtcYn: boolean
- projectGtcYn: boolean
- agreementYn: boolean
- }
- biddingCompanyId: number
- biddingId: number
- hasExistingContracts?: boolean
- }>
+ vendors: VendorWithContactInfo[]
generatedPdfs: Array<{
key: string
buffer: number[]
@@ -108,82 +125,206 @@ export function BiddingInvitationDialog({
vendors,
biddingId,
biddingTitle,
- projectName,
onSend,
}: BiddingInvitationDialogProps) {
const { toast } = useToast()
const [isPending, startTransition] = useTransition()
// 기본계약 관련 상태
- const [existingContracts, setExistingContracts] = React.useState<any[]>([])
+ const [, setExistingContractsList] = React.useState<Array<{ vendorId: number; biddingCompanyId: number }>>([])
const [isGeneratingPdfs, setIsGeneratingPdfs] = React.useState(false)
const [pdfGenerationProgress, setPdfGenerationProgress] = React.useState(0)
const [currentGeneratingContract, setCurrentGeneratingContract] = React.useState('')
+ // 벤더 정보 상태 (담당자 선택 기능 포함)
+ const [vendorData, setVendorData] = React.useState<VendorWithContactInfo[]>([])
+
// 기본계약서 템플릿 관련 상태
- const [availableTemplates, setAvailableTemplates] = React.useState<any[]>([])
+ const [availableTemplates, setAvailableTemplates] = React.useState<BasicContractTemplate[]>([])
const [selectedContracts, setSelectedContracts] = React.useState<SelectedContract[]>([])
const [isLoadingTemplates, setIsLoadingTemplates] = React.useState(false)
const [additionalMessage, setAdditionalMessage] = React.useState('')
+ // 커스텀 이메일 관련 상태
+ const [showCustomEmailForm, setShowCustomEmailForm] = React.useState<Record<number, boolean>>({})
+ const [customEmailInputs, setCustomEmailInputs] = React.useState<Record<number, { email: string; name: string }>>({})
+ const [customEmailCounter, setCustomEmailCounter] = React.useState(0)
+
+ // 벤더 정보 업데이트 함수
+ const updateVendor = React.useCallback((vendorId: number, updates: Partial<VendorWithContactInfo>) => {
+ setVendorData(prev => prev.map(vendor =>
+ vendor.vendorId === vendorId ? { ...vendor, ...updates } : vendor
+ ))
+ }, [])
+
+ // CC 이메일 토글
+ const toggleAdditionalEmail = React.useCallback((vendorId: number, email: string) => {
+ setVendorData(prev => prev.map(vendor => {
+ if (vendor.vendorId === vendorId) {
+ const additionalEmails = vendor.additionalEmails.includes(email)
+ ? vendor.additionalEmails.filter(e => e !== email)
+ : [...vendor.additionalEmails, email]
+ return { ...vendor, additionalEmails }
+ }
+ return vendor
+ }))
+ }, [])
+
+ // 커스텀 이메일 추가
+ const addCustomEmail = React.useCallback((vendorId: number) => {
+ const input = customEmailInputs[vendorId]
+ if (!input?.email) return
+
+ setVendorData(prev => prev.map(vendor => {
+ if (vendor.vendorId === vendorId) {
+ const newCustomEmail: CustomEmail = {
+ id: `custom-${customEmailCounter}`,
+ email: input.email,
+ name: input.name || input.email
+ }
+ return {
+ ...vendor,
+ customEmails: [...vendor.customEmails, newCustomEmail]
+ }
+ }
+ return vendor
+ }))
+
+ setCustomEmailInputs(prev => ({
+ ...prev,
+ [vendorId]: { email: '', name: '' }
+ }))
+ setCustomEmailCounter(prev => prev + 1)
+ }, [customEmailInputs, customEmailCounter])
+
+ // 커스텀 이메일 제거
+ const removeCustomEmail = React.useCallback((vendorId: number, customEmailId: string) => {
+ setVendorData(prev => prev.map(vendor => {
+ if (vendor.vendorId === vendorId) {
+ return {
+ ...vendor,
+ customEmails: vendor.customEmails.filter(ce => ce.id !== customEmailId),
+ additionalEmails: vendor.additionalEmails.filter(email =>
+ !vendor.customEmails.find(ce => ce.id === customEmailId)?.email || email !== vendor.customEmails.find(ce => ce.id === customEmailId)?.email
+ )
+ }
+ }
+ return vendor
+ }))
+ }, [])
+
+ // 총 수신자 수 계산
+ const totalRecipientCount = React.useMemo(() => {
+ return vendorData.reduce((sum, vendor) => {
+ return sum + 1 + vendor.additionalEmails.length // 주 수신자 1명 + CC
+ }, 0)
+ }, [vendorData])
+
// 선택된 업체들 (사전견적에서 선정된 업체들만)
const selectedVendors = React.useMemo(() =>
vendors.filter(vendor => vendor.ndaYn || vendor.generalGtcYn || vendor.projectGtcYn || vendor.agreementYn),
[vendors]
)
- // 기존 계약이 있는 업체들과 없는 업체들 분리
+ // 기존 계약이 있는 업체들 분리
const vendorsWithExistingContracts = React.useMemo(() =>
- selectedVendors.filter(vendor =>
- existingContracts.some((ec: any) =>
- ec.vendorId === vendor.vendorId && ec.biddingCompanyId === vendor.biddingCompanyId
- )
- ),
- [selectedVendors, existingContracts]
+ vendorData.filter(vendor => vendor.hasExistingContracts),
+ [vendorData]
)
- const vendorsWithoutExistingContracts = React.useMemo(() =>
- selectedVendors.filter(vendor =>
- !existingContracts.some((ec: any) =>
- ec.vendorId === vendor.vendorId && ec.biddingCompanyId === vendor.biddingCompanyId
- )
- ),
- [selectedVendors, existingContracts]
- )
-
- // 다이얼로그가 열릴 때 기존 계약 조회 및 템플릿 로드
+ // 다이얼로그가 열릴 때 기존 계약 조회, 템플릿 로드, 벤더 담당자 로드
React.useEffect(() => {
- if (open) {
+ if (open && selectedVendors.length > 0) {
const fetchInitialData = async () => {
setIsLoadingTemplates(true);
try {
- const [contractsResult, templatesData] = await Promise.all([
- getSelectedVendorsForBidding(biddingId),
+ const [existingContractsResult, templatesData] = await Promise.all([
+ getExistingBasicContractsForBidding(biddingId),
getActiveContractTemplates(),
]);
- // 기존 계약 조회 (사전견적에서 보낸 기본계약 확인) - 서버 액션 사용
- const existingContracts = await getExistingBasicContractsForBidding(biddingId);
- setExistingContracts(existingContracts.success ? existingContracts.contracts || [] : []);
+ // 기존 계약 조회
+ const contracts = existingContractsResult.success ? existingContractsResult.contracts || [] : [];
+ const typedContracts = contracts.map(c => ({
+ vendorId: c.vendorId || 0,
+ biddingCompanyId: c.biddingCompanyId || 0
+ }));
+ setExistingContractsList(typedContracts);
// 템플릿 로드 (4개 타입만 필터링)
- // 4개 템플릿 타입만 필터링: 비밀, General, Project, 기술자료
const allowedTemplateNames = ['비밀', 'General GTC', '기술', '기술자료'];
const rawTemplates = templatesData.templates || [];
- const filteredTemplates = rawTemplates.filter((template: any) =>
+ const filteredTemplates = rawTemplates.filter((template: BasicContractTemplate) =>
allowedTemplateNames.some(allowedName =>
template.templateName.includes(allowedName) ||
allowedName.includes(template.templateName)
)
);
- setAvailableTemplates(filteredTemplates as any);
- const initialSelected = filteredTemplates.map((template: any) => ({
+ setAvailableTemplates(filteredTemplates);
+ const initialSelected = filteredTemplates.map((template: BasicContractTemplate) => ({
templateId: template.id,
templateName: template.templateName,
contractType: template.templateName,
checked: false
}));
setSelectedContracts(initialSelected);
+
+ // 벤더 담당자 정보 병렬로 가져오기
+ const vendorContactsPromises = selectedVendors.map(vendor =>
+ getVendorContacts({
+ page: 1,
+ perPage: 100,
+ flags: [],
+ sort: [],
+ filters: [],
+ joinOperator: 'and',
+ search: '',
+ contactName: '',
+ contactPosition: '',
+ contactEmail: '',
+ contactPhone: ''
+ }, vendor.vendorId)
+ .then(result => ({
+ vendorId: vendor.vendorId,
+ contacts: (result.data || []).map(contact => ({
+ id: contact.id,
+ contactName: contact.contactName,
+ contactEmail: contact.contactEmail,
+ contactPhone: contact.contactPhone,
+ contactPosition: contact.contactPosition,
+ contactDepartment: contact.contactDepartment
+ }))
+ }))
+ .catch(() => ({
+ vendorId: vendor.vendorId,
+ contacts: []
+ }))
+ );
+
+ const vendorContactsResults = await Promise.all(vendorContactsPromises);
+ const vendorContactsMap = new Map(vendorContactsResults.map(result => [result.vendorId, result.contacts]));
+
+ // vendorData 초기화 (담당자 정보 포함)
+ const initialVendorData: VendorWithContactInfo[] = selectedVendors.map(vendor => {
+ const hasExistingContract = typedContracts.some((ec) =>
+ ec.vendorId === vendor.vendorId && ec.biddingCompanyId === vendor.biddingCompanyId
+ );
+ const vendorContacts = vendorContactsMap.get(vendor.vendorId) || [];
+
+ // 주 수신자 기본값: 벤더의 기본 이메일 (vendorEmail)
+ const defaultEmail = vendor.vendorEmail || (vendorContacts.length > 0 ? vendorContacts[0].contactEmail : '');
+ console.log(defaultEmail, "defaultEmail");
+ return {
+ ...vendor,
+ contacts: vendorContacts,
+ selectedMainEmail: defaultEmail,
+ additionalEmails: [],
+ customEmails: [],
+ hasExistingContracts: hasExistingContract
+ };
+ });
+
+ setVendorData(initialVendorData);
} catch (error) {
console.error('초기 데이터 로드 실패:', error);
toast({
@@ -193,13 +334,14 @@ export function BiddingInvitationDialog({
});
setAvailableTemplates([]);
setSelectedContracts([]);
+ setVendorData([]);
} finally {
setIsLoadingTemplates(false);
}
}
fetchInitialData();
}
- }, [open, biddingId, toast]);
+ }, [open, biddingId, selectedVendors, toast]);
const handleOpenChange = (open: boolean) => {
onOpenChange(open)
@@ -209,6 +351,7 @@ export function BiddingInvitationDialog({
setIsGeneratingPdfs(false)
setPdfGenerationProgress(0)
setCurrentGeneratingContract('')
+ setVendorData([])
}
}
@@ -245,32 +388,32 @@ export function BiddingInvitationDialog({
vendorId,
}),
});
-
+
if (!prepareResponse.ok) {
throw new Error("템플릿 준비 실패");
}
-
+
const { template: preparedTemplate, templateData } = await prepareResponse.json();
-
+
// 2. 템플릿 파일 다운로드
const templateResponse = await fetch("/api/contracts/get-template", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ templatePath: preparedTemplate.filePath }),
});
-
+
const templateBlob = await templateResponse.blob();
const templateFile = new window.File([templateBlob], "template.docx", {
type: "application/vnd.openxmlformats-officedocument.wordprocessingml.document"
});
-
+
// 3. PDFTron WebViewer로 PDF 변환
const { default: WebViewer } = await import("@pdftron/webviewer");
-
+
const tempDiv = document.createElement('div');
tempDiv.style.display = 'none';
document.body.appendChild(tempDiv);
-
+
try {
const instance = await WebViewer(
{
@@ -280,29 +423,29 @@ export function BiddingInvitationDialog({
},
tempDiv
);
-
+
const { Core } = instance;
const { createDocument } = Core;
-
+
const templateDoc = await createDocument(templateFile, {
filename: templateFile.name,
extension: 'docx',
});
-
+
// 변수 치환 적용
await templateDoc.applyTemplateValues(templateData);
-
+
// PDF 변환
const fileData = await templateDoc.getFileData();
const pdfBuffer = await Core.officeToPDFBuffer(fileData, { extension: 'docx' });
-
+
const fileName = `${template.templateName}_${Date.now()}.pdf`;
-
+
return {
buffer: Array.from(pdfBuffer), // Uint8Array를 일반 배열로 변환
fileName
};
-
+
} finally {
if (tempDiv.parentNode) {
document.body.removeChild(tempDiv);
@@ -333,43 +476,39 @@ export function BiddingInvitationDialog({
setPdfGenerationProgress(0)
let generatedCount = 0;
- for (const vendor of selectedVendors) {
- // 사전견적에서 이미 기본계약을 보낸 벤더인지 확인
- const hasExistingContract = existingContracts.some((ec: any) =>
- ec.vendorId === vendor.vendorId && ec.biddingCompanyId === vendor.biddingCompanyId
- );
-
- if (hasExistingContract) {
- console.log(`벤더 ${vendor.vendorName}는 사전견적에서 이미 기본계약을 받았으므로 건너뜁니다.`);
+ for (const vendorWithContact of vendorData) {
+ // 기존 계약이 있는 경우 건너뛰기
+ if (vendorWithContact.hasExistingContracts) {
+ console.log(`벤더 ${vendorWithContact.vendorName}는 사전견적에서 이미 기본계약을 받았으므로 건너뜁니다.`);
generatedCount++;
- setPdfGenerationProgress((generatedCount / selectedVendors.length) * 100);
+ setPdfGenerationProgress((generatedCount / vendorData.length) * 100);
continue;
}
- for (const contract of selectedContractTemplates) {
- setCurrentGeneratingContract(`${vendor.vendorName} - ${contract.templateName}`);
- const templateDetails = availableTemplates.find(t => t.id === contract.templateId);
-
- if (templateDetails) {
- const pdfData = await generateBasicContractPdf(templateDetails, vendor.vendorId);
- // sendBiddingBasicContracts와 동일한 키 형식 사용
- let contractType = '';
- if (contract.templateName.includes('비밀')) {
- contractType = 'NDA';
- } else if (contract.templateName.includes('General GTC')) {
- contractType = 'General_GTC';
- } else if (contract.templateName.includes('기술') && !contract.templateName.includes('기술자료')) {
- contractType = 'Project_GTC';
- } else if (contract.templateName.includes('기술자료')) {
- contractType = '기술자료';
+ for (const contract of selectedContractTemplates) {
+ setCurrentGeneratingContract(`${vendorWithContact.vendorName} - ${contract.templateName}`);
+ const templateDetails = availableTemplates.find(t => t.id === contract.templateId);
+
+ if (templateDetails) {
+ const pdfData = await generateBasicContractPdf(templateDetails, vendorWithContact.vendorId);
+ // sendBiddingBasicContracts와 동일한 키 형식 사용
+ let contractType = '';
+ if (contract.templateName.includes('비밀')) {
+ contractType = 'NDA';
+ } else if (contract.templateName.includes('General GTC')) {
+ contractType = 'General_GTC';
+ } else if (contract.templateName.includes('기술') && !contract.templateName.includes('기술자료')) {
+ contractType = 'Project_GTC';
+ } else if (contract.templateName.includes('기술자료')) {
+ contractType = '기술자료';
+ }
+ const key = `${vendorWithContact.vendorId}_${contractType}_${contract.templateName}`;
+ generatedPdfsMap.set(key, pdfData);
}
- const key = `${vendor.vendorId}_${contractType}_${contract.templateName}`;
- generatedPdfsMap.set(key, pdfData);
}
+ generatedCount++;
+ setPdfGenerationProgress((generatedCount / vendorData.length) * 100);
}
- generatedCount++;
- setPdfGenerationProgress((generatedCount / selectedVendors.length) * 100);
- }
setIsGeneratingPdfs(false);
@@ -382,30 +521,6 @@ export function BiddingInvitationDialog({
generatedPdfs = pdfsArray;
}
- const vendorData = selectedVendors.map(vendor => {
- const hasExistingContract = existingContracts.some((ec: any) =>
- ec.vendorId === vendor.vendorId && ec.biddingCompanyId === vendor.biddingCompanyId
- );
-
- return {
- vendorId: vendor.vendorId,
- vendorName: vendor.vendorName,
- vendorCode: vendor.vendorCode,
- vendorCountry: vendor.vendorCountry,
- selectedMainEmail: vendor.contactEmail || '',
- additionalEmails: [],
- contractRequirements: {
- ndaYn: vendor.ndaYn || false,
- generalGtcYn: vendor.generalGtcYn || false,
- projectGtcYn: vendor.projectGtcYn || false,
- agreementYn: vendor.agreementYn || false
- },
- biddingCompanyId: vendor.biddingCompanyId,
- biddingId: vendor.biddingId,
- hasExistingContracts: hasExistingContract
- };
- });
-
await onSend({
vendors: vendorData,
generatedPdfs: generatedPdfs,
@@ -428,7 +543,7 @@ export function BiddingInvitationDialog({
return (
<Dialog open={open} onOpenChange={handleOpenChange}>
- <DialogContent className="sm:max-w-[800px] max-h-[90vh] flex flex-col" style={{width:900, maxWidth:900}}>
+ <DialogContent className="sm:max-w-[800px] max-h-[90vh] flex flex-col" style={{ width: 900, maxWidth: 900 }}>
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<Mail className="w-5 h-5" />
@@ -453,72 +568,299 @@ export function BiddingInvitationDialog({
</Alert>
)}
- {/* 대상 업체 정보 */}
- <Card>
- <CardHeader className="pb-3">
- <CardTitle className="flex items-center gap-2 text-base">
- <Building2 className="h-5 w-5 text-green-600" />
- 초대 대상 업체 ({selectedVendors.length}개)
- </CardTitle>
- </CardHeader>
- <CardContent>
- {selectedVendors.length === 0 ? (
- <div className="text-center py-6 text-muted-foreground">
- 초대 가능한 업체가 없습니다.
- </div>
- ) : (
- <div className="space-y-4">
- {/* 계약서가 생성될 업체들 */}
- {vendorsWithoutExistingContracts.length > 0 && (
- <div>
- <h4 className="text-sm font-medium text-green-700 mb-2 flex items-center gap-2">
- <CheckCircle className="h-4 w-4 text-green-600" />
- 계약서 생성 대상 ({vendorsWithoutExistingContracts.length}개)
- </h4>
- <div className="space-y-2 max-h-32 overflow-y-auto">
- {vendorsWithoutExistingContracts.map((vendor) => (
- <div key={vendor.vendorId} className="flex items-center gap-2 text-sm p-2 bg-green-50 rounded border border-green-200">
- <CheckCircle className="h-4 w-4 text-green-600" />
- <span className="font-medium">{vendor.vendorName}</span>
- <Badge variant="outline" className="text-xs">
- {vendor.vendorCode}
- </Badge>
- </div>
- ))}
- </div>
- </div>
- )}
+ {/* 대상 업체 정보 - 테이블 형식 */}
+ <div className="space-y-4">
+ <div className="flex items-center justify-between">
+ <div className="flex items-center gap-2 text-sm font-medium">
+ <Building2 className="h-4 w-4" />
+ 초대 대상 업체 ({vendorData.length})
+ </div>
+ <Badge variant="outline" className="flex items-center gap-1">
+ <Users className="h-3 w-3" />
+ 총 {totalRecipientCount}명
+ </Badge>
+ </div>
- {/* 기존 계약이 있는 업체들 */}
- {vendorsWithExistingContracts.length > 0 && (
- <div>
- <h4 className="text-sm font-medium text-orange-700 mb-2 flex items-center gap-2">
- <X className="h-4 w-4 text-orange-600" />
- 기존 계약 존재 (계약서 재생성 건너뜀) ({vendorsWithExistingContracts.length}개)
- </h4>
- <div className="space-y-2 max-h-32 overflow-y-auto">
- {vendorsWithExistingContracts.map((vendor) => (
- <div key={vendor.vendorId} className="flex items-center gap-2 text-sm p-2 bg-orange-50 rounded border border-orange-200">
- <X className="h-4 w-4 text-orange-600" />
- <span className="font-medium">{vendor.vendorName}</span>
- <Badge variant="outline" className="text-xs">
- {vendor.vendorCode}
- </Badge>
- <Badge variant="secondary" className="text-xs bg-orange-100 text-orange-800">
- 계약 존재 (재생성 건너뜀)
- </Badge>
- <Badge variant="outline" className="text-xs border-green-500 text-green-700">
- 본입찰 초대
- </Badge>
- </div>
- ))}
- </div>
- </div>
- )}
- </div>
- )}
- </CardContent>
- </Card>
+ {vendorData.length === 0 ? (
+ <div className="text-center py-6 text-muted-foreground border rounded-lg">
+ 초대 가능한 업체가 없습니다.
+ </div>
+ ) : (
+ <div className="border rounded-lg overflow-hidden">
+ <table className="w-full">
+ <thead className="bg-muted/50 border-b">
+ <tr>
+ <th className="text-left p-2 text-xs font-medium">No.</th>
+ <th className="text-left p-2 text-xs font-medium">업체명</th>
+ <th className="text-left p-2 text-xs font-medium">주 수신자</th>
+ <th className="text-left p-2 text-xs font-medium">CC</th>
+ <th className="text-left p-2 text-xs font-medium">작업</th>
+ </tr>
+ </thead>
+ <tbody>
+ {vendorData.map((vendor, index) => {
+ const allContacts = vendor.contacts || [];
+ const allEmails = [
+ // 벤더의 기본 이메일을 첫 번째로 표시
+ ...(vendor.vendorEmail ? [{
+ value: vendor.vendorEmail,
+ label: `${vendor.vendorEmail}`,
+ email: vendor.vendorEmail,
+ type: 'vendor' as const
+ }] : []),
+ // 담당자 이메일들
+ ...allContacts.map(c => ({
+ value: c.contactEmail,
+ label: `${c.contactName} ${c.contactPosition ? `(${c.contactPosition})` : ''}`,
+ email: c.contactEmail,
+ type: 'contact' as const
+ })),
+ // 커스텀 이메일들
+ ...vendor.customEmails.map(c => ({
+ value: c.email,
+ label: c.name || c.email,
+ email: c.email,
+ type: 'custom' as const
+ }))
+ ];
+
+ const ccEmails = allEmails.filter(e => e.value !== vendor.selectedMainEmail);
+ const selectedMainEmailInfo = allEmails.find(e => e.value === vendor.selectedMainEmail);
+ const isFormOpen = showCustomEmailForm[vendor.vendorId];
+
+ return (
+ <React.Fragment key={vendor.vendorId}>
+ <tr className="border-b hover:bg-muted/20">
+ <td className="p-2">
+ <div className="flex items-center gap-1">
+ <div className="flex items-center justify-center w-5 h-5 rounded-full bg-primary/10 text-primary text-xs font-medium">
+ {index + 1}
+ </div>
+ </div>
+ </td>
+ <td className="p-2">
+ <div className="space-y-1">
+ <div className="font-medium text-sm">{vendor.vendorName}</div>
+ <div className="flex items-center gap-1">
+ <Badge variant="outline" className="text-xs">
+ {vendor.vendorCountry || vendor.vendorCode}
+ </Badge>
+ </div>
+ </div>
+ </td>
+ <td className="p-2">
+ <Select
+ value={vendor.selectedMainEmail}
+ onValueChange={(value) => updateVendor(vendor.vendorId, { selectedMainEmail: value })}
+ >
+ <SelectTrigger className="h-7 text-xs w-[200px]">
+ <SelectValue placeholder="선택하세요">
+ {selectedMainEmailInfo && (
+ <div className="flex items-center gap-1">
+ {selectedMainEmailInfo.type === 'custom' && <UserPlus className="h-3 w-3 text-green-500" />}
+ <span className="truncate">{selectedMainEmailInfo.label}</span>
+ </div>
+ )}
+ </SelectValue>
+ </SelectTrigger>
+ <SelectContent>
+ {allEmails.map((email) => (
+ <SelectItem key={email.value} value={email.value} className="text-xs">
+ <div className="flex items-center gap-1">
+ {email.type === 'custom' && <UserPlus className="h-3 w-3 text-green-500" />}
+ <span>{email.label}</span>
+ </div>
+ </SelectItem>
+ ))}
+ </SelectContent>
+ </Select>
+ {!vendor.selectedMainEmail && (
+ <span className="text-xs text-red-500">필수</span>
+ )}
+ </td>
+ <td className="p-2">
+ <Popover>
+ <PopoverTrigger asChild>
+ <Button variant="outline" className="h-7 text-xs">
+ {vendor.additionalEmails.length > 0
+ ? `${vendor.additionalEmails.length}명`
+ : "선택"
+ }
+ <ChevronDown className="ml-1 h-3 w-3" />
+ </Button>
+ </PopoverTrigger>
+ <PopoverContent className="w-48 p-2">
+ <div className="max-h-48 overflow-y-auto space-y-1">
+ {ccEmails.map((email) => (
+ <div key={email.value} className="flex items-center space-x-1 p-1">
+ <Checkbox
+ checked={vendor.additionalEmails.includes(email.value)}
+ onCheckedChange={() => toggleAdditionalEmail(vendor.vendorId, email.value)}
+ className="h-3 w-3"
+ />
+ <label className="text-xs cursor-pointer flex-1 truncate">
+ {email.label}
+ </label>
+ </div>
+ ))}
+ </div>
+ </PopoverContent>
+ </Popover>
+ </td>
+ <td className="p-2">
+ <div className="flex items-center gap-1">
+ <Button
+ variant={isFormOpen ? "default" : "ghost"}
+ size="sm"
+ className="h-6 w-6 p-0"
+ onClick={() => {
+ setShowCustomEmailForm(prev => ({
+ ...prev,
+ [vendor.vendorId]: !prev[vendor.vendorId]
+ }));
+ }}
+ >
+ {isFormOpen ? <X className="h-3 w-3" /> : <Plus className="h-3 w-3" />}
+ </Button>
+ {vendor.customEmails.length > 0 && (
+ <Badge variant="secondary" className="text-xs">
+ +{vendor.customEmails.length}
+ </Badge>
+ )}
+ </div>
+ </td>
+ </tr>
+
+ {/* 인라인 수신자 추가 폼 */}
+ {isFormOpen && (
+ <tr className="bg-muted/10 border-b">
+ <td colSpan={5} className="p-4">
+ <div className="space-y-3">
+ <div className="flex items-center justify-between mb-2">
+ <div className="flex items-center gap-2 text-sm font-medium">
+ <UserPlus className="h-4 w-4" />
+ 수신자 추가 - {vendor.vendorName}
+ </div>
+ <Button
+ variant="ghost"
+ size="sm"
+ className="h-6 w-6 p-0"
+ onClick={() => setShowCustomEmailForm(prev => ({
+ ...prev,
+ [vendor.vendorId]: false
+ }))}
+ >
+ <X className="h-3 w-3" />
+ </Button>
+ </div>
+
+ <div className="flex gap-2 items-end">
+ <div className="w-[150px]">
+ <Label className="text-xs mb-1 block">이름 (선택)</Label>
+ <Input
+ placeholder="홍길동"
+ className="h-8 text-sm"
+ value={customEmailInputs[vendor.vendorId]?.name || ''}
+ onChange={(e) => setCustomEmailInputs(prev => ({
+ ...prev,
+ [vendor.vendorId]: {
+ ...prev[vendor.vendorId],
+ name: e.target.value
+ }
+ }))}
+ />
+ </div>
+ <div className="flex-1">
+ <Label className="text-xs mb-1 block">이메일 <span className="text-red-500">*</span></Label>
+ <Input
+ type="email"
+ placeholder="example@company.com"
+ className="h-8 text-sm"
+ value={customEmailInputs[vendor.vendorId]?.email || ''}
+ onChange={(e) => setCustomEmailInputs(prev => ({
+ ...prev,
+ [vendor.vendorId]: {
+ ...prev[vendor.vendorId],
+ email: e.target.value
+ }
+ }))}
+ onKeyPress={(e) => {
+ if (e.key === 'Enter') {
+ e.preventDefault();
+ addCustomEmail(vendor.vendorId);
+ }
+ }}
+ />
+ </div>
+ <Button
+ size="sm"
+ className="h-8 px-4"
+ onClick={() => addCustomEmail(vendor.vendorId)}
+ disabled={!customEmailInputs[vendor.vendorId]?.email}
+ >
+ <Plus className="h-3 w-3 mr-1" />
+ 추가
+ </Button>
+ <Button
+ variant="outline"
+ size="sm"
+ className="h-8 px-4"
+ onClick={() => {
+ setCustomEmailInputs(prev => ({
+ ...prev,
+ [vendor.vendorId]: { email: '', name: '' }
+ }));
+ setShowCustomEmailForm(prev => ({
+ ...prev,
+ [vendor.vendorId]: false
+ }));
+ }}
+ >
+ 취소
+ </Button>
+ </div>
+
+ {/* 추가된 커스텀 이메일 목록 */}
+ {vendor.customEmails.length > 0 && (
+ <div className="mt-3 pt-3 border-t">
+ <div className="text-xs text-muted-foreground mb-2">추가된 수신자 목록</div>
+ <div className="grid grid-cols-2 xl:grid-cols-3 gap-2">
+ {vendor.customEmails.map((custom) => (
+ <div key={custom.id} className="flex items-center justify-between bg-background rounded-md p-2">
+ <div className="flex items-center gap-2 min-w-0">
+ <UserPlus className="h-3 w-3 text-green-500 flex-shrink-0" />
+ <div className="min-w-0">
+ <div className="text-sm font-medium truncate">{custom.name}</div>
+ <div className="text-xs text-muted-foreground truncate">{custom.email}</div>
+ </div>
+ </div>
+ <Button
+ variant="ghost"
+ size="sm"
+ className="h-6 w-6 p-0 flex-shrink-0"
+ onClick={() => removeCustomEmail(vendor.vendorId, custom.id)}
+ >
+ <X className="h-3 w-3" />
+ </Button>
+ </div>
+ ))}
+ </div>
+ </div>
+ )}
+ </div>
+ </td>
+ </tr>
+ )}
+ </React.Fragment>
+ );
+ })}
+ </tbody>
+ </table>
+ </div>
+ )}
+ </div>
+
+ <Separator />
{/* 기본계약서 선택 */}
<Card>
@@ -685,4 +1027,4 @@ export function BiddingInvitationDialog({
</DialogContent>
</Dialog>
)
-}
+} \ No newline at end of file
diff --git a/lib/bidding/failure/biddings-failure-columns.tsx b/lib/bidding/failure/biddings-failure-columns.tsx
new file mode 100644
index 00000000..8a888079
--- /dev/null
+++ b/lib/bidding/failure/biddings-failure-columns.tsx
@@ -0,0 +1,320 @@
+"use client"
+
+import * as React from "react"
+import { type ColumnDef } from "@tanstack/react-table"
+import { Badge } from "@/components/ui/badge"
+import { Button } from "@/components/ui/button"
+import {
+ Eye, Calendar, FileX, DollarSign, AlertTriangle, RefreshCw
+} from "lucide-react"
+import {
+ Tooltip,
+ TooltipContent,
+ TooltipProvider,
+ TooltipTrigger,
+} from "@/components/ui/tooltip"
+import {
+ DropdownMenu,
+ DropdownMenuContent,
+ DropdownMenuItem,
+ DropdownMenuSeparator,
+ DropdownMenuTrigger,
+} from "@/components/ui/dropdown-menu"
+import { DataTableColumnHeaderSimple } from "@/components/data-table/data-table-column-simple-header"
+import {
+ biddingStatusLabels,
+ contractTypeLabels,
+} from "@/db/schema"
+import { formatDate } from "@/lib/utils"
+import { DataTableRowAction } from "@/types/table"
+
+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 GetColumnsProps {
+ setRowAction: React.Dispatch<React.SetStateAction<DataTableRowAction<BiddingFailureItem> | null>>
+}
+
+// 상태별 배지 색상
+const getStatusBadgeVariant = (status: string) => {
+ switch (status) {
+ case 'bidding_disposal':
+ return 'destructive'
+ default:
+ return 'outline'
+ }
+}
+
+// 금액 포맷팅
+const formatCurrency = (amount: string | number | null, currency = 'KRW') => {
+ if (!amount) return '-'
+
+ const numAmount = typeof amount === 'string' ? parseFloat(amount) : amount
+ if (isNaN(numAmount)) return '-'
+
+ return new Intl.NumberFormat('ko-KR', {
+ style: 'currency',
+ currency: currency,
+ minimumFractionDigits: 0,
+ maximumFractionDigits: 0,
+ }).format(numAmount)
+}
+
+export function getBiddingsFailureColumns({ setRowAction }: GetColumnsProps): ColumnDef<BiddingFailureItem>[] {
+
+ return [
+ // ░░░ 입찰번호 ░░░
+ {
+ accessorKey: "biddingNumber",
+ header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="입찰번호" />,
+ cell: ({ row }) => (
+ <div className="font-mono text-sm">
+ {row.original.biddingNumber}
+ </div>
+ ),
+ size: 120,
+ meta: { excelHeader: "입찰번호" },
+ },
+
+ // ░░░ 입찰명 ░░░
+ {
+ accessorKey: "title",
+ header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="입찰명" />,
+ cell: ({ row }) => (
+ <div className="truncate max-w-[200px]" title={row.original.title}>
+ <Button
+ variant="link"
+ className="p-0 h-auto text-left justify-start font-bold underline"
+ onClick={() => setRowAction({ row, type: "view" })}
+ >
+ <div className="whitespace-pre-line">
+ {row.original.title}
+ </div>
+ </Button>
+ </div>
+ ),
+ size: 200,
+ meta: { excelHeader: "입찰명" },
+ },
+
+ // ░░░ 진행상태 ░░░
+ {
+ accessorKey: "status",
+ header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="진행상태" />,
+ cell: ({ row }) => (
+ <Badge variant={getStatusBadgeVariant(row.original.status)}>
+ {biddingStatusLabels[row.original.status]}
+ </Badge>
+ ),
+ size: 120,
+ meta: { excelHeader: "진행상태" },
+ },
+
+ // ░░░ 계약구분 ░░░
+ {
+ accessorKey: "contractType",
+ header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="계약구분" />,
+ cell: ({ row }) => (
+ <Badge variant="outline">
+ {contractTypeLabels[row.original.contractType]}
+ </Badge>
+ ),
+ size: 100,
+ meta: { excelHeader: "계약구분" },
+ },
+
+ // ░░░ 내정가 ░░░
+ {
+ accessorKey: "targetPrice",
+ header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="내정가" />,
+ cell: ({ row }) => {
+ const price = row.original.targetPrice
+ const currency = row.original.currency || 'KRW'
+
+ return (
+ <div className="text-sm font-medium text-orange-600">
+ {price ? formatCurrency(price, currency) : '-'}
+ </div>
+ )
+ },
+ size: 120,
+ meta: { excelHeader: "내정가" },
+ },
+
+ // ░░░ 통화 ░░░
+ {
+ accessorKey: "currency",
+ header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="통화" />,
+ cell: ({ row }) => (
+ <span className="font-mono text-sm">{row.original.currency || 'KRW'}</span>
+ ),
+ size: 60,
+ meta: { excelHeader: "통화" },
+ },
+
+ // ░░░ 입찰등록일 ░░░
+ {
+ accessorKey: "biddingRegistrationDate",
+ header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="입찰등록일" />,
+ cell: ({ row }) => (
+ <span className="text-sm">{formatDate(row.original.biddingRegistrationDate, "KR")}</span>
+ ),
+ size: 100,
+ meta: { excelHeader: "입찰등록일" },
+ },
+
+ // ░░░ 입찰담당자 ░░░
+ {
+ accessorKey: "bidPicName",
+ header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="입찰담당자" />,
+ cell: ({ row }) => {
+ const bidPic = row.original.bidPicName
+ const supplyPic = row.original.supplyPicName
+
+ const displayName = bidPic || supplyPic || "-"
+ return <span className="text-sm">{displayName}</span>
+ },
+ size: 100,
+ meta: { excelHeader: "입찰담당자" },
+ },
+
+ // ░░░ 유찰일 ░░░
+ {
+ id: "disposalDate",
+ header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="유찰일" />,
+ cell: ({ row }) => (
+ <div className="flex items-center gap-1">
+ <AlertTriangle className="h-4 w-4 text-red-500" />
+ <span className="text-sm">{formatDate(row.original.disposalDate, "KR")}</span>
+ </div>
+ ),
+ size: 100,
+ meta: { excelHeader: "유찰일" },
+ },
+
+ // ░░░ 폐찰일 ░░░
+ {
+ id: "disposalUpdatedAt",
+ header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="폐찰일" />,
+ cell: ({ row }) => (
+ <div className="flex items-center gap-1">
+ <FileX className="h-4 w-4 text-red-500" />
+ <span className="text-sm">{formatDate(row.original.disposalUpdatedAt, "KR")}</span>
+ </div>
+ ),
+ size: 100,
+ meta: { excelHeader: "폐찰일" },
+ },
+
+ // ░░░ 폐찰수정자 ░░░
+ {
+ id: "disposalUpdatedBy",
+ header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="폐찰수정자" />,
+ cell: ({ row }) => (
+ <span className="text-sm">{row.original.disposalUpdatedBy || '-'}</span>
+ ),
+ size: 100,
+ meta: { excelHeader: "폐찰수정자" },
+ },
+
+ // ░░░ P/R번호 ░░░
+ {
+ accessorKey: "prNumber",
+ header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="P/R번호" />,
+ cell: ({ row }) => (
+ <span className="font-mono text-sm">{row.original.prNumber || '-'}</span>
+ ),
+ size: 100,
+ meta: { excelHeader: "P/R번호" },
+ },
+
+ // ░░░ 등록자 ░░░
+ {
+ accessorKey: "createdBy",
+ header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="등록자" />,
+ cell: ({ row }) => (
+ <span className="text-sm">{row.original.createdBy || '-'}</span>
+ ),
+ size: 100,
+ meta: { excelHeader: "등록자" },
+ },
+
+ // ░░░ 등록일시 ░░░
+ {
+ accessorKey: "createdAt",
+ header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="등록일시" />,
+ cell: ({ row }) => (
+ <span className="text-sm">{formatDate(row.original.createdAt, "KR")}</span>
+ ),
+ size: 100,
+ meta: { excelHeader: "등록일시" },
+ },
+
+ // ═══════════════════════════════════════════════════════════════
+ // 액션
+ // ═══════════════════════════════════════════════════════════════
+ {
+ id: "actions",
+ header: "액션",
+ cell: ({ row }) => (
+ <DropdownMenu>
+ <DropdownMenuTrigger asChild>
+ <Button variant="ghost" className="h-8 w-8 p-0">
+ <span className="sr-only">메뉴 열기</span>
+ <FileX className="h-4 w-4" />
+ </Button>
+ </DropdownMenuTrigger>
+ <DropdownMenuContent align="end">
+ <DropdownMenuItem onClick={() => setRowAction({ row, type: "view" })}>
+ <Eye className="mr-2 h-4 w-4" />
+ 상세보기
+ </DropdownMenuItem>
+ <DropdownMenuItem onClick={() => setRowAction({ row, type: "history" })}>
+ <Calendar className="mr-2 h-4 w-4" />
+ 이력보기
+ </DropdownMenuItem>
+ <DropdownMenuSeparator />
+ <DropdownMenuItem onClick={() => setRowAction({ row, type: "rebid" })}>
+ <RefreshCw className="mr-2 h-4 w-4" />
+ 재입찰
+ </DropdownMenuItem>
+ </DropdownMenuContent>
+ </DropdownMenu>
+ ),
+ 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<ReturnType<typeof getBiddingsForFailure>>
+ ]
+ >
+}
+
+export function BiddingsFailureTable({ promises }: BiddingsFailureTableProps) {
+ const [biddingsResult] = React.use(promises)
+
+ // biddingsResult에서 data와 pageCount 추출
+ const { data, pageCount } = biddingsResult
+
+ const [isCompact, setIsCompact] = React.useState<boolean>(false)
+ const [specMeetingDialogOpen, setSpecMeetingDialogOpen] = React.useState(false)
+ const [prDocumentsDialogOpen, setPrDocumentsDialogOpen] = React.useState(false)
+ const [selectedBidding, setSelectedBidding] = React.useState<BiddingFailureItem | null>(null)
+
+ const [rowAction, setRowAction] = React.useState<DataTableRowAction<BiddingFailureItem> | 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<BiddingFailureItem>[] = [
+ {
+ 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<BiddingFailureItem>[] = [
+ { 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 (
+ <>
+ <DataTable
+ table={table}
+ compact={isCompact}
+ >
+ <DataTableAdvancedToolbar
+ table={table}
+ filterFields={advancedFilterFields}
+ shallow={false}
+ enableCompactToggle={true}
+ compactStorageKey="biddingsFailureTableCompact"
+ onCompactChange={handleCompactChange}
+ >
+ </DataTableAdvancedToolbar>
+ </DataTable>
+
+ {/* 사양설명회 다이얼로그 */}
+ <SpecificationMeetingDialog
+ open={specMeetingDialogOpen}
+ onOpenChange={handleSpecMeetingDialogClose}
+ bidding={selectedBidding}
+ />
+
+ {/* PR 문서 다이얼로그 */}
+ <PrDocumentsDialog
+ open={prDocumentsDialogOpen}
+ onOpenChange={handlePrDocumentsDialogClose}
+ bidding={selectedBidding}
+ />
+ </>
+ )
+}
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<T> {
- success: boolean;
- data?: T;
- error?: string;
-}
+// PR 관련 타입과 컴포넌트는 bidding-pr-documents-dialog.tsx로 이동됨
// 파일 다운로드 훅
const useFileDownload = () => {
@@ -212,52 +156,6 @@ const FileDownloadLink: React.FC<FileDownloadLinkProps> = ({
);
};
-// 파일 다운로드 버튼 컴포넌트 (간소화된 버전)
-interface FileDownloadButtonProps {
- filePath: string;
- fileName: string;
- fileSize?: number;
- title?: string | null;
- variant?: "download" | "preview";
- size?: "sm" | "default" | "lg";
-}
-
-const FileDownloadButton: React.FC<FileDownloadButtonProps> = ({
- 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 (
- <Button
- onClick={() => handleDownload(filePath, fileName, { action: variant })}
- disabled={isDownloading}
- size={size}
- variant="outline"
- className="gap-2"
- >
- <Icon className="h-4 w-4" />
- {isDownloading ? "처리중..." : (
- variant === "preview" && fileInfo.canPreview ? "미리보기" : "다운로드"
- )}
- {fileSize && size !== "sm" && (
- <span className="text-xs text-muted-foreground">
- ({formatFileSize(fileSize)})
- </span>
- )}
- </Button>
- );
-};
-
// 사양설명회 다이얼로그
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<PRDetails | null>(null);
- const [loading, setLoading] = useState(false);
- const [error, setError] = useState<string | null>(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 (
- <Dialog open={open} onOpenChange={onOpenChange}>
- <DialogContent className="max-w-7xl max-h-[90vh]">
- <DialogHeader>
- <DialogTitle className="flex items-center gap-2">
- <PackageIcon className="h-5 w-5" />
- PR 문서
- </DialogTitle>
- <DialogDescription>
- {bidding?.title}의 PR 문서 및 아이템 정보입니다.
- </DialogDescription>
- </DialogHeader>
-
- <ScrollArea className="max-h-[75vh]">
- {loading ? (
- <div className="flex items-center justify-center py-8">
- <div className="text-center">
- <div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary mx-auto mb-2"></div>
- <p className="text-sm text-muted-foreground">로딩 중...</p>
- </div>
- </div>
- ) : error ? (
- <div className="flex items-center justify-center py-8">
- <div className="text-center">
- <p className="text-sm text-destructive mb-2">{error}</p>
- <Button onClick={fetchPRData} size="sm">
- 다시 시도
- </Button>
- </div>
- </div>
- ) : data ? (
- <div className="space-y-6">
- {/* PR 문서 목록 */}
- {data.documents.length > 0 && (
- <Card>
- <CardHeader>
- <CardTitle className="text-lg flex items-center gap-2">
- <FileTextIcon className="h-5 w-5" />
- PR 문서 ({data.documents.length}개)
- </CardTitle>
- </CardHeader>
- <CardContent>
- <Table>
- <TableHeader>
- <TableRow>
- <TableHead>문서명</TableHead>
- <TableHead>파일명</TableHead>
- <TableHead>버전</TableHead>
- <TableHead>크기</TableHead>
- <TableHead>등록일</TableHead>
- <TableHead>등록자</TableHead>
- <TableHead className="text-right">다운로드</TableHead>
- </TableRow>
- </TableHeader>
- <TableBody>
- {data.documents.map((doc) => (
- <TableRow key={doc.id}>
- <TableCell className="font-medium">
- {doc.documentName}
- {doc.description && (
- <div className="text-xs text-muted-foreground mt-1">
- {doc.description}
- </div>
- )}
- </TableCell>
- <TableCell>
- <FileDownloadLink
- filePath={doc.filePath}
- fileName={doc.originalFileName}
- fileSize={doc.fileSize}
- />
- </TableCell>
- <TableCell>
- {doc.version ? (
- <Badge variant="outline">{doc.version}</Badge>
- ) : "-"}
- </TableCell>
- <TableCell>{formatFileSize(doc.fileSize)}</TableCell>
- <TableCell>
- {new Date(doc.registeredAt).toLocaleDateString('ko-KR')}
- </TableCell>
- <TableCell>{doc.registeredBy || "-"}</TableCell>
- <TableCell className="text-right">
- <FileDownloadButton
- filePath={doc.filePath}
- fileName={doc.originalFileName}
- fileSize={doc.fileSize}
- variant="download"
- size="sm"
- />
- </TableCell>
- </TableRow>
- ))}
- </TableBody>
- </Table>
- </CardContent>
- </Card>
- )}
+// 폐찰하기 다이얼로그
+interface BidClosureDialogProps {
+ open: boolean;
+ onOpenChange: (open: boolean) => void;
+ bidding: BiddingListItem | null;
+ userId: string;
+}
- {/* PR 아이템 테이블 */}
- {data.items.length > 0 && (
- <Card>
- <CardHeader>
- <CardTitle className="text-lg flex items-center gap-2">
- <HashIcon className="h-5 w-5" />
- PR 아이템 ({data.items.length}개)
- </CardTitle>
- </CardHeader>
- <CardContent>
- <Table>
- <TableHeader>
- <TableRow>
- <TableHead className="w-[100px]">아이템 번호</TableHead>
- <TableHead className="w-[150px]">PR 번호</TableHead>
- <TableHead>아이템 정보</TableHead>
- <TableHead className="w-[120px]">수량</TableHead>
- <TableHead className="w-[120px]">단가</TableHead>
- <TableHead className="w-[120px]">중량</TableHead>
- <TableHead className="w-[120px]">요청 납기</TableHead>
- <TableHead className="w-[200px]">스펙 문서</TableHead>
- </TableRow>
- </TableHeader>
- <TableBody>
- {data.items.map((item) => (
- <TableRow key={item.id}>
- <TableCell className="font-medium">
- {item.itemNumber}
- </TableCell>
- <TableCell>
- {item.prNumber || "-"}
- </TableCell>
- <TableCell>
- <div>
- {item.itemInfo && (
- <div className="font-medium text-sm mb-1">{item.itemInfo}</div>
- )}
- {item.materialDescription && (
- <div className="text-xs text-muted-foreground">
- {item.materialDescription}
- </div>
- )}
- </div>
- </TableCell>
- <TableCell>
- <div className="flex items-center gap-1">
- <PackageIcon className="h-3 w-3 text-muted-foreground" />
- <span className="text-sm">
- {item.quantity ? `${item.quantity.toLocaleString()} ${item.quantityUnit || ""}` : "-"}
- </span>
- </div>
- </TableCell>
- <TableCell>
- <div className="flex items-center gap-1">
- <DollarSignIcon className="h-3 w-3 text-muted-foreground" />
- <span className="text-sm">
- {formatCurrency(item.annualUnitPrice, item.currency)}
- </span>
- </div>
- </TableCell>
- <TableCell>
- <div className="flex items-center gap-1">
- <WeightIcon className="h-3 w-3 text-muted-foreground" />
- <span className="text-sm">
- {formatWeight(item.totalWeight, item.weightUnit)}
- </span>
- </div>
- </TableCell>
- <TableCell>
- {item.requestedDeliveryDate ? (
- <div className="flex items-center gap-1">
- <CalendarIcon className="h-3 w-3 text-muted-foreground" />
- <span className="text-sm">
- {new Date(item.requestedDeliveryDate).toLocaleDateString('ko-KR')}
- </span>
- </div>
- ) : "-"}
- </TableCell>
- <TableCell>
- <div className="space-y-1">
- <div className="flex items-center gap-2">
- <Badge variant={item.hasSpecDocument ? "default" : "secondary"} className="text-xs">
- {item.hasSpecDocument ? "있음" : "없음"}
- </Badge>
- {item.specDocuments.length > 0 && (
- <span className="text-xs text-muted-foreground">
- ({item.specDocuments.length}개)
- </span>
- )}
- </div>
- {item.specDocuments.length > 0 && (
- <div className="space-y-1">
- {item.specDocuments.map((doc, index) => (
- <div key={doc.id} className="text-xs">
- <FileDownloadLink
- filePath={doc.filePath}
- fileName={doc.originalFileName}
- fileSize={doc.fileSize}
- title={doc.title}
- className="text-xs"
- />
- </div>
- ))}
- </div>
- )}
- </div>
- </TableCell>
- </TableRow>
- ))}
- </TableBody>
- </Table>
- </CardContent>
- </Card>
- )}
+export function BidClosureDialog({
+ open,
+ onOpenChange,
+ bidding,
+ userId
+}: BidClosureDialogProps) {
+ const [description, setDescription] = useState('')
+ const [files, setFiles] = useState<File[]>([])
+ 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<HTMLInputElement>) => {
+ if (e.target.files) {
+ setFiles(Array.from(e.target.files))
+ }
+ }
+
+ if (!bidding) return null
+
+ return (
+ <Dialog open={open} onOpenChange={onOpenChange}>
+ <DialogContent className="max-w-2xl">
+ <DialogHeader>
+ <DialogTitle className="flex items-center gap-2">
+ <FileXIcon className="h-5 w-5 text-destructive" />
+ 폐찰하기
+ </DialogTitle>
+ <DialogDescription>
+ {bidding.title} ({bidding.biddingNumber})를 폐찰합니다.
+ </DialogDescription>
+ </DialogHeader>
+
+ <form onSubmit={handleSubmit} className="space-y-4">
+ <div className="space-y-2">
+ <Label htmlFor="description">폐찰 사유 <span className="text-destructive">*</span></Label>
+ <Textarea
+ id="description"
+ placeholder="폐찰 사유를 입력해주세요..."
+ value={description}
+ onChange={(e) => setDescription(e.target.value)}
+ className="min-h-[100px]"
+ required
+ />
+ </div>
+
+ <div className="space-y-2">
+ <Label htmlFor="files">첨부파일</Label>
+ <Input
+ id="files"
+ type="file"
+ multiple
+ onChange={handleFileChange}
+ className="cursor-pointer"
+ accept=".pdf,.doc,.docx,.xls,.xlsx,.txt,.jpg,.jpeg,.png"
+ />
+ {files.length > 0 && (
+ <div className="text-sm text-muted-foreground">
+ 선택된 파일: {files.map(f => f.name).join(', ')}
+ </div>
+ )}
+ </div>
+
+ <div className="flex justify-end gap-2 pt-4">
+ <Button
+ type="button"
+ variant="outline"
+ onClick={() => onOpenChange(false)}
+ disabled={isSubmitting}
+ >
+ 취소
+ </Button>
+ <Button
+ type="submit"
+ variant="destructive"
+ disabled={isSubmitting || !description.trim()}
+ >
+ {isSubmitting ? '처리 중...' : '폐찰하기'}
+ </Button>
+ </div>
+ </form>
+ </DialogContent>
+ </Dialog>
+ )
+}
- {/* 데이터가 없는 경우 */}
- {data.documents.length === 0 && data.items.length === 0 && (
- <div className="text-center py-8">
- <FileTextIcon className="h-12 w-12 text-muted-foreground mx-auto mb-2" />
- <p className="text-muted-foreground">PR 문서가 없습니다.</p>
- </div>
- )}
- </div>
- ) : null}
- </ScrollArea>
- </DialogContent>
- </Dialog>
- );
-} \ No newline at end of file
+// Re-export for backward compatibility
+export { PrDocumentsDialog } from './bidding-pr-documents-dialog' \ No newline at end of file
diff --git a/lib/bidding/list/bidding-pr-documents-dialog.tsx b/lib/bidding/list/bidding-pr-documents-dialog.tsx
new file mode 100644
index 00000000..ad377ee5
--- /dev/null
+++ b/lib/bidding/list/bidding-pr-documents-dialog.tsx
@@ -0,0 +1,405 @@
+"use client"
+
+import * as React from "react"
+import { useState, useEffect, useCallback } from "react"
+import {
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ 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 { ScrollArea } from "@/components/ui/scroll-area"
+import {
+ CalendarIcon,
+ FileTextIcon,
+ PackageIcon,
+ HashIcon,
+ DollarSignIcon,
+} from "lucide-react"
+import { BiddingListItem } from "@/db/schema"
+import { formatFileSize } from "@/lib/file-download"
+import { getPRDetailsAction, type PRDetails } from "../service"
+
+// 파일 다운로드 컴포넌트
+const FileDownloadLink = ({
+ filePath,
+ fileName,
+ fileSize,
+ title,
+ className = ""
+}: {
+ filePath: string;
+ fileName: string;
+ fileSize: number;
+ title?: string | null;
+ className?: string;
+}) => {
+ return (
+ <a
+ href={filePath}
+ download={fileName}
+ className={`text-blue-600 hover:underline ${className}`}
+ title={title || fileName}
+ >
+ {title || fileName} <span className="text-xs text-gray-500">({formatFileSize(fileSize)})</span>
+ </a>
+ );
+};
+
+const FileDownloadButton = ({
+ filePath,
+ fileName,
+ variant = "download",
+ size = "sm"
+}: {
+ filePath: string;
+ fileName: string;
+ variant?: "download" | "preview";
+ size?: "sm" | "default";
+}) => {
+ return (
+ <Button
+ variant="outline"
+ size={size}
+ asChild
+ >
+ <a href={filePath} download={fileName}>
+ {variant === "download" ? "다운로드" : "미리보기"}
+ </a>
+ </Button>
+ );
+};
+
+// PR 문서 다이얼로그
+interface PrDocumentsDialogProps {
+ open: boolean;
+ onOpenChange: (open: boolean) => void;
+ bidding: BiddingListItem | null;
+}
+
+export function PrDocumentsDialog({
+ open,
+ onOpenChange,
+ bidding
+}: PrDocumentsDialogProps) {
+ const [data, setData] = useState<PRDetails | null>(null);
+ const [loading, setLoading] = useState(false);
+ const [error, setError] = useState<string | null>(null);
+
+ const fetchPRData = useCallback(async () => {
+ if (!bidding) return;
+
+ setLoading(true);
+ setError(null);
+
+ try {
+ const result = await getPRDetailsAction(bidding.id);
+
+ if (result.success && result.data) {
+ setData(result.data as PRDetails);
+ } else {
+ setError(result.error || "PR 문서 정보를 불러올 수 없습니다.");
+ }
+ } catch (err) {
+ setError("데이터 로딩 중 오류가 발생했습니다.");
+ console.error("Failed to fetch PR data:", err);
+ } finally {
+ setLoading(false);
+ }
+ }, [bidding]);
+
+ useEffect(() => {
+ if (open && bidding) {
+ fetchPRData();
+ }
+ }, [open, bidding, fetchPRData]);
+
+ const formatCurrency = (amount: number | null, currency: string | null) => {
+ if (amount == null) return "-";
+ return `${amount.toLocaleString()} ${currency || ""}`;
+ };
+
+ return (
+ <Dialog open={open} onOpenChange={onOpenChange}>
+ <DialogContent className="max-w-7xl max-h-[90vh]" style={{ maxWidth: "80vw" }}>
+ <DialogHeader>
+ <DialogTitle className="flex items-center gap-2">
+ <PackageIcon className="h-5 w-5" />
+ PR 및 문서 정보
+ </DialogTitle>
+ <DialogDescription>
+ {bidding?.title}의 PR 문서 및 아이템 정보입니다.
+ </DialogDescription>
+ </DialogHeader>
+
+ <ScrollArea className="max-h-[75vh]">
+ {loading ? (
+ <div className="flex items-center justify-center py-8">
+ <div className="text-center">
+ <div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary mx-auto mb-2"></div>
+ <p className="text-sm text-muted-foreground">로딩 중...</p>
+ </div>
+ </div>
+ ) : error ? (
+ <div className="flex items-center justify-center py-8">
+ <div className="text-center">
+ <p className="text-sm text-destructive mb-2">{error}</p>
+ <Button onClick={fetchPRData} size="sm">
+ 다시 시도
+ </Button>
+ </div>
+ </div>
+ ) : data ? (
+ <div className="space-y-6">
+ {/* PR 문서 목록 */}
+ {data.documents.length > 0 && (
+ <Card>
+ <CardHeader>
+ <CardTitle className="text-lg flex items-center gap-2">
+ <FileTextIcon className="h-5 w-5" />
+ PR 문서 ({data.documents.length}개)
+ </CardTitle>
+ </CardHeader>
+ <CardContent>
+ <Table>
+ <TableHeader>
+ <TableRow>
+ <TableHead>문서명</TableHead>
+ <TableHead>파일명</TableHead>
+ <TableHead>버전</TableHead>
+ <TableHead>크기</TableHead>
+ <TableHead>등록일</TableHead>
+ <TableHead>등록자</TableHead>
+ <TableHead className="text-right">다운로드</TableHead>
+ </TableRow>
+ </TableHeader>
+ <TableBody>
+ {data.documents.map((doc) => (
+ <TableRow key={doc.id}>
+ <TableCell className="font-medium">
+ {doc.documentName}
+ {doc.description && (
+ <div className="text-xs text-muted-foreground mt-1">
+ {doc.description}
+ </div>
+ )}
+ </TableCell>
+ <TableCell>
+ <FileDownloadLink
+ filePath={doc.filePath}
+ fileName={doc.originalFileName}
+ fileSize={doc.fileSize}
+ />
+ </TableCell>
+ <TableCell>
+ {doc.version ? (
+ <Badge variant="outline">{doc.version}</Badge>
+ ) : "-"}
+ </TableCell>
+ <TableCell>{formatFileSize(doc.fileSize)}</TableCell>
+ <TableCell>
+ {new Date(doc.registeredAt).toLocaleDateString('ko-KR')}
+ </TableCell>
+ <TableCell>{doc.registeredBy || "-"}</TableCell>
+ <TableCell className="text-right">
+ <FileDownloadButton
+ filePath={doc.filePath}
+ fileName={doc.originalFileName}
+ variant="download"
+ size="sm"
+ />
+ </TableCell>
+ </TableRow>
+ ))}
+ </TableBody>
+ </Table>
+ </CardContent>
+ </Card>
+ )}
+
+ {/* PR 아이템 테이블 */}
+ {data.items.length > 0 && (
+ <Card>
+ <CardHeader>
+ <CardTitle className="text-lg flex items-center gap-2">
+ <HashIcon className="h-5 w-5" />
+ PR 아이템 ({data.items.length}개)
+ </CardTitle>
+ </CardHeader>
+ <CardContent>
+ <div className="overflow-x-auto">
+ <Table>
+ <TableHeader>
+ <TableRow>
+ <TableHead className="w-[80px]">아이템 번호</TableHead>
+ <TableHead className="w-[120px]">PR 번호</TableHead>
+ <TableHead className="w-[150px]">자재그룹</TableHead>
+ <TableHead className="w-[150px]">자재</TableHead>
+ <TableHead className="w-[200px]">품목정보</TableHead>
+ <TableHead className="w-[100px]">수량</TableHead>
+ <TableHead className="w-[80px]">구매단위</TableHead>
+ <TableHead className="w-[100px]">내정단가</TableHead>
+ <TableHead className="w-[100px]">내정금액</TableHead>
+ <TableHead className="w-[100px]">예산금액</TableHead>
+ <TableHead className="w-[100px]">실적금액</TableHead>
+ <TableHead className="w-[100px]">WBS코드</TableHead>
+ <TableHead className="w-[100px]">요청 납기</TableHead>
+ <TableHead className="w-[150px]">스펙 문서</TableHead>
+ </TableRow>
+ </TableHeader>
+ <TableBody>
+ {data.items.map((item) => (
+ <TableRow key={item.id}>
+ <TableCell className="font-medium font-mono text-xs">
+ {item.itemNumber || "-"}
+ </TableCell>
+ <TableCell className="font-mono text-xs">
+ {item.prNumber || "-"}
+ </TableCell>
+ <TableCell>
+ <div>
+ {item.materialGroupNumber && (
+ <div className="font-mono text-xs">{item.materialGroupNumber}</div>
+ )}
+ {item.materialGroupInfo && (
+ <div className="text-xs text-muted-foreground">{item.materialGroupInfo}</div>
+ )}
+ {!item.materialGroupNumber && !item.materialGroupInfo && "-"}
+ </div>
+ </TableCell>
+ <TableCell>
+ <div>
+ {item.materialNumber && (
+ <div className="font-mono text-xs">{item.materialNumber}</div>
+ )}
+ {item.materialInfo && (
+ <div className="text-xs text-muted-foreground">{item.materialInfo}</div>
+ )}
+ {!item.materialNumber && !item.materialInfo && "-"}
+ </div>
+ </TableCell>
+ <TableCell>
+ <div className="text-xs">
+ {item.itemInfo || "-"}
+ </div>
+ </TableCell>
+ <TableCell>
+ <div className="flex items-center gap-1">
+ <PackageIcon className="h-3 w-3 text-muted-foreground" />
+ <span className="text-sm">
+ {item.quantity ? `${item.quantity.toLocaleString()} ${item.quantityUnit || ""}` : "-"}
+ </span>
+ </div>
+ </TableCell>
+ <TableCell className="text-xs">
+ {item.purchaseUnit || "-"}
+ </TableCell>
+ <TableCell>
+ <div className="flex items-center gap-1">
+ <DollarSignIcon className="h-3 w-3 text-muted-foreground" />
+ <span className="text-sm">
+ {formatCurrency(item.targetUnitPrice, item.targetCurrency)}
+ </span>
+ </div>
+ </TableCell>
+ <TableCell>
+ <div className="text-sm font-medium">
+ {formatCurrency(item.targetAmount, item.targetCurrency)}
+ </div>
+ </TableCell>
+ <TableCell>
+ <div className="text-sm">
+ {formatCurrency(item.budgetAmount, item.budgetCurrency)}
+ </div>
+ </TableCell>
+ <TableCell>
+ <div className="text-sm">
+ {formatCurrency(item.actualAmount, item.actualCurrency)}
+ </div>
+ </TableCell>
+ <TableCell>
+ <div>
+ {item.wbsCode && (
+ <div className="font-mono text-xs">{item.wbsCode}</div>
+ )}
+ {item.wbsName && (
+ <div className="text-xs text-muted-foreground">{item.wbsName}</div>
+ )}
+ {!item.wbsCode && !item.wbsName && "-"}
+ </div>
+ </TableCell>
+ <TableCell>
+ {item.requestedDeliveryDate ? (
+ <div className="flex items-center gap-1">
+ <CalendarIcon className="h-3 w-3 text-muted-foreground" />
+ <span className="text-sm">
+ {new Date(item.requestedDeliveryDate).toLocaleDateString('ko-KR')}
+ </span>
+ </div>
+ ) : "-"}
+ </TableCell>
+ <TableCell>
+ <div className="space-y-1">
+ <div className="flex items-center gap-2">
+ <Badge variant={item.hasSpecDocument ? "default" : "secondary"} className="text-xs">
+ {item.hasSpecDocument ? "있음" : "없음"}
+ </Badge>
+ {item.specDocuments.length > 0 && (
+ <span className="text-xs text-muted-foreground">
+ ({item.specDocuments.length}개)
+ </span>
+ )}
+ </div>
+ {item.specDocuments.length > 0 && (
+ <div className="space-y-1">
+ {item.specDocuments.map((doc) => (
+ <div key={doc.id} className="text-xs">
+ <FileDownloadLink
+ filePath={doc.filePath}
+ fileName={doc.originalFileName}
+ fileSize={doc.fileSize}
+ title={doc.title}
+ className="text-xs"
+ />
+ </div>
+ ))}
+ </div>
+ )}
+ </div>
+ </TableCell>
+ </TableRow>
+ ))}
+ </TableBody>
+ </Table>
+ </div>
+ </CardContent>
+ </Card>
+ )}
+
+ {/* 데이터가 없는 경우 */}
+ {data.documents.length === 0 && data.items.length === 0 && (
+ <div className="text-center py-8">
+ <FileTextIcon className="h-12 w-12 text-muted-foreground mx-auto mb-2" />
+ <p className="text-muted-foreground">PR 문서가 없습니다.</p>
+ </div>
+ )}
+ </div>
+ ) : null}
+ </ScrollArea>
+ </DialogContent>
+ </Dialog>
+ );
+}
+
diff --git a/lib/bidding/list/biddings-table-columns.tsx b/lib/bidding/list/biddings-table-columns.tsx
index d6044e93..10966e0e 100644
--- a/lib/bidding/list/biddings-table-columns.tsx
+++ b/lib/bidding/list/biddings-table-columns.tsx
@@ -5,18 +5,10 @@ import { type ColumnDef } from "@tanstack/react-table"
import { Checkbox } from "@/components/ui/checkbox"
import { Badge } from "@/components/ui/badge"
import { Button } from "@/components/ui/button"
-import { getUserCodeByEmail } from "@/lib/bidding/service"
import {
- Eye, Edit, MoreHorizontal, FileText, Users, Calendar,
- Building, Package, DollarSign, Clock, CheckCircle, XCircle,
- AlertTriangle
+ Eye, Edit, MoreHorizontal, FileX
} from "lucide-react"
-import {
- Tooltip,
- TooltipContent,
- TooltipProvider,
- TooltipTrigger,
-} from "@/components/ui/tooltip"
+
import {
DropdownMenu,
DropdownMenuContent,
@@ -30,14 +22,13 @@ import { DataTableRowAction } from "@/types/table"
// BiddingListItem에 manager 정보 추가
type BiddingListItemWithManagerCode = BiddingListItem & {
- managerName?: string | null
- managerCode?: string | null
+ bidPicName?: string | null
+ supplyPicName?: string | null
}
import {
biddingStatusLabels,
contractTypeLabels,
biddingTypeLabels,
- awardCountLabels
} from "@/db/schema"
import { formatDate } from "@/lib/utils"
@@ -68,23 +59,6 @@ const getStatusBadgeVariant = (status: string) => {
}
}
-// 금액 포맷팅
-const formatCurrency = (amount: string | number | null, currency = 'KRW') => {
- if (!amount) return '-'
-
- const numAmount = typeof amount === 'string' ? parseFloat(amount) : amount
- if (isNaN(numAmount)) return '-'
-
- return new Intl.NumberFormat('ko-KR', {
- style: 'currency',
- currency: currency,
- minimumFractionDigits: 0,
- maximumFractionDigits: 0,
- }).format(numAmount)
-}
-
-
-
export function getBiddingsColumns({ setRowAction }: GetColumnsProps): ColumnDef<BiddingListItemWithManagerCode>[] {
return [
@@ -132,442 +106,256 @@ export function getBiddingsColumns({ setRowAction }: GetColumnsProps): ColumnDef
meta: { excelHeader: "입찰 No." },
},
+ // ░░░ 원입찰번호 ░░░
+ {
+ accessorKey: "originalBiddingNumber",
+ header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="원입찰번호" />,
+ cell: ({ row }) => (
+ <div className="font-mono text-sm">
+ {row.original.originalBiddingNumber || '-'}
+ </div>
+ ),
+ size: 120,
+ meta: { excelHeader: "원입찰번호" },
+ },
+ // ░░░ 프로젝트명 ░░░
+ {
+ accessorKey: "projectName",
+ header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="프로젝트명" />,
+ cell: ({ row }) => (
+ <div className="truncate max-w-[150px]" title={row.original.projectName || ''}>
+ {row.original.projectName || '-'}
+ </div>
+ ),
+ size: 150,
+ meta: { excelHeader: "프로젝트명" },
+ },
+ // ░░░ 입찰명 ░░░
+ {
+ accessorKey: "title",
+ header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="입찰명" />,
+ cell: ({ row }) => (
+ <div className="truncate max-w-[200px]" title={row.original.title}>
+ <Button
+ variant="link"
+ className="p-0 h-auto text-left justify-start font-bold underline"
+ onClick={() => setRowAction({ row, type: "view" })}
+ >
+ <div className="whitespace-pre-line">
+ {row.original.title}
+ </div>
+ </Button>
+ </div>
+ ),
+ size: 200,
+ meta: { excelHeader: "입찰명" },
+ },
+ // ░░░ 계약구분 ░░░
+ {
+ accessorKey: "contractType",
+ header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="계약구분" />,
+ cell: ({ row }) => (
+ <Badge variant="outline">
+ {contractTypeLabels[row.original.contractType]}
+ </Badge>
+ ),
+ size: 100,
+ meta: { excelHeader: "계약구분" },
+ },
// ░░░ 입찰상태 ░░░
{
accessorKey: "status",
- header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="입찰상태" />,
+ header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="진행상태" />,
cell: ({ row }) => (
<Badge variant={getStatusBadgeVariant(row.original.status)}>
{biddingStatusLabels[row.original.status]}
</Badge>
),
size: 120,
- meta: { excelHeader: "입찰상태" },
+ meta: { excelHeader: "진행상태" },
},
-
- // ░░░ 긴급여부 ░░░
+ // ░░░ 입찰유형 ░░░
{
- accessorKey: "isUrgent",
- header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="긴급여부" />,
- cell: ({ row }) => {
- const isUrgent = row.original.isUrgent
+ accessorKey: "biddingType",
+ header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="입찰유형" />,
+ cell: ({ row }) => (
+ <Badge variant="secondary">
+ {biddingTypeLabels[row.original.biddingType]}
+ </Badge>
+ ),
+ size: 100,
+ meta: { excelHeader: "입찰유형" },
+ },
- return isUrgent ? (
- <div className="flex items-center gap-1">
- <AlertTriangle className="h-4 w-4 text-red-600" />
- <Badge variant="destructive" className="text-xs">
- 긴급
- </Badge>
- </div>
- ) : (
- <div className="flex items-center gap-1">
- <CheckCircle className="h-4 w-4 text-green-600" />
- <span className="text-xs text-muted-foreground">일반</span>
- </div>
- )
- },
- size: 90,
- meta: { excelHeader: "긴급여부" },
+ // ░░░ 통화 ░░░
+ {
+ accessorKey: "currency",
+ header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="통화" />,
+ cell: ({ row }) => (
+ <span className="font-mono text-sm">{row.original.currency}</span>
+ ),
+ size: 60,
+ meta: { excelHeader: "통화" },
},
- // ░░░ 사전견적 ░░░
+ // ░░░ 예산 ░░░
{
- id: "preQuote",
- header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="사전견적" />,
- cell: ({ row }) => {
- const hasPreQuote = ['request_for_quotation', 'received_quotation'].includes(row.original.status)
- const preQuoteDate = row.original.preQuoteDate
-
- return hasPreQuote ? (
- <div className="flex items-center gap-1">
- <CheckCircle className="h-4 w-4 text-green-600" />
- {preQuoteDate && (
- <span className="text-xs text-muted-foreground">
- {formatDate(preQuoteDate, "KR")}
- </span>
- )}
- </div>
- ) : (
- <XCircle className="h-4 w-4 text-gray-400" />
- )
- },
- size: 90,
- meta: { excelHeader: "사전견적" },
+ accessorKey: "budget",
+ header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="예산" />,
+ cell: ({ row }) => (
+ <span className="text-sm font-medium">
+ {row.original.budget}
+ </span>
+ ),
+ size: 120,
+ meta: { excelHeader: "예산" },
},
+ // ░░░ 내정가 ░░░
+ {
+ accessorKey: "targetPrice",
+ header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="내정가" />,
+ cell: ({ row }) => (
+ <span className="text-sm font-medium text-orange-600">
+ {row.original.targetPrice}
+ </span>
+ ),
+ size: 120,
+ meta: { excelHeader: "내정가" },
+ },
// ░░░ 입찰담당자 ░░░
{
- accessorKey: "managerName",
+ accessorKey: "bidPicName",
header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="입찰담당자" />,
cell: ({ row }) => {
- const name = row.original.managerName || "-";
- const managerCode = row.original.managerCode || "";
- return name === "-" ? "-" : `${name}(${managerCode})`;
+ const name = row.original.bidPicName || "-";
+ return name;
},
size: 100,
meta: { excelHeader: "입찰담당자" },
},
-
- // ═══════════════════════════════════════════════════════════════
- // 프로젝트 정보
- // ═══════════════════════════════════════════════════════════════
+
+ // ░░░ 입찰등록일 ░░░
{
- header: "프로젝트 정보",
- columns: [
- {
- accessorKey: "projectName",
- header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="프로젝트명" />,
- cell: ({ row }) => (
- <div className="truncate max-w-[150px]" title={row.original.projectName || ''}>
- {row.original.projectName || '-'}
- </div>
- ),
- size: 150,
- meta: { excelHeader: "프로젝트명" },
- },
-
- {
- accessorKey: "itemName",
- header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="품목명" />,
- cell: ({ row }) => (
- <div className="truncate max-w-[150px]" title={row.original.itemName || ''}>
- {row.original.itemName || '-'}
- </div>
- ),
- size: 150,
- meta: { excelHeader: "품목명" },
- },
-
- {
- accessorKey: "title",
- header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="입찰명" />,
- cell: ({ row }) => (
- <div className="truncate max-w-[200px]" title={row.original.title}>
- <Button
- variant="link"
- className="p-0 h-auto text-left justify-start font-bold underline"
- onClick={() => setRowAction({ row, type: "view" })}
- >
- <div className="whitespace-pre-line">
- {row.original.title}
- </div>
- </Button>
- </div>
- ),
- size: 200,
- meta: { excelHeader: "입찰명" },
- },
- ]
+ accessorKey: "biddingRegistrationDate",
+ header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="입찰등록일" />,
+ cell: ({ row }) => (
+ <span className="text-sm">{formatDate(row.original.biddingRegistrationDate , "KR")}</span>
+ ),
+ size: 100,
+ meta: { excelHeader: "입찰등록일" },
},
- // ═══════════════════════════════════════════════════════════════
- // 계약 정보
- // ═══════════════════════════════════════════════════════════════
{
- header: "계약 정보",
- columns: [
- {
- accessorKey: "contractType",
- header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="계약구분" />,
- cell: ({ row }) => (
- <Badge variant="outline">
- {contractTypeLabels[row.original.contractType]}
- </Badge>
- ),
- size: 100,
- meta: { excelHeader: "계약구분" },
- },
-
- {
- accessorKey: "biddingType",
- header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="입찰유형" />,
- cell: ({ row }) => (
- <Badge variant="secondary">
- {biddingTypeLabels[row.original.biddingType]}
- </Badge>
- ),
- size: 100,
- meta: { excelHeader: "입찰유형" },
- },
-
- {
- accessorKey: "awardCount",
- header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="낙찰수" />,
- cell: ({ row }) => (
- <Badge variant="outline">
- {awardCountLabels[row.original.awardCount]}
- </Badge>
- ),
- size: 80,
- meta: { excelHeader: "낙찰수" },
- },
-
- {
- id: "contractPeriod",
- header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="계약기간" />,
- cell: ({ row }) => {
- const startDate = row.original.contractStartDate
- const endDate = row.original.contractEndDate
-
- if (!startDate || !endDate) {
- return <span className="text-muted-foreground">-</span>
- }
-
- return (
- <div className="text-xs max-w-[120px] truncate" title={`${formatDate(startDate, "KR")} ~ ${formatDate(endDate, "KR")}`}>
- {formatDate(startDate, "KR")} ~ {formatDate(endDate, "KR")}
- </div>
- )
- },
- size: 120,
- meta: { excelHeader: "계약기간" },
- },
- ]
+ id: "submissionPeriod",
+ header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="입찰서제출기간" />,
+ cell: ({ row }) => {
+ const startDate = row.original.submissionStartDate
+ const endDate = row.original.submissionEndDate
+
+ if (!startDate || !endDate) return <span className="text-muted-foreground">-</span>
+
+ const now = new Date()
+ const isActive = now >= new Date(startDate) && now <= new Date(endDate)
+ const isPast = now > new Date(endDate)
+
+ return (
+ <div className="text-xs">
+ <div className={`${isActive ? 'text-green-600 font-medium' : isPast ? 'text-red-600' : 'text-gray-600'}`}>
+ {formatDate(startDate, "KR")} ~ {formatDate(endDate, "KR")}
+ </div>
+ {isActive && (
+ <Badge variant="default" className="text-xs mt-1">진행중</Badge>
+ )}
+ </div>
+ )
+ },
+ size: 140,
+ meta: { excelHeader: "입찰서제출기간" },
},
-
- // ═══════════════════════════════════════════════════════════════
- // 일정 정보
- // ═══════════════════════════════════════════════════════════════
+ // ░░░ 사양설명회 ░░░
{
- header: "일정 정보",
- columns: [
- {
- id: "submissionPeriod",
- header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="입찰서제출기간" />,
- cell: ({ row }) => {
- const startDate = row.original.submissionStartDate
- const endDate = row.original.submissionEndDate
-
- if (!startDate || !endDate) return <span className="text-muted-foreground">-</span>
-
- const now = new Date()
- const isActive = now >= new Date(startDate) && now <= new Date(endDate)
- const isPast = now > new Date(endDate)
-
- return (
- <div className="text-xs">
- <div className={`${isActive ? 'text-green-600 font-medium' : isPast ? 'text-red-600' : 'text-gray-600'}`}>
- {formatDate(startDate, "KR")} ~ {formatDate(endDate, "KR")}
- </div>
- {isActive && (
- <Badge variant="default" className="text-xs mt-1">진행중</Badge>
- )}
- </div>
- )
- },
- size: 140,
- meta: { excelHeader: "입찰서제출기간" },
- },
-
- {
- accessorKey: "hasSpecificationMeeting",
- header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="사양설명회" />,
- cell: ({ row }) => {
- const hasMeeting = row.original.hasSpecificationMeeting
-
- return (
- <Button
- variant="ghost"
- size="sm"
- className={`p-1 h-auto ${hasMeeting ? 'text-blue-600' : 'text-gray-400'}`}
- onClick={() => hasMeeting && setRowAction({ row, type: "specification_meeting" })}
- disabled={!hasMeeting}
- >
- {hasMeeting ? 'Yes' : 'No'}
- </Button>
- )
- },
- size: 100,
- meta: { excelHeader: "사양설명회" },
- },
- ]
+ accessorKey: "hasSpecificationMeeting",
+ header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="사양설명회" />,
+ cell: ({ row }) => {
+ const hasMeeting = row.original.hasSpecificationMeeting
+
+ return (
+ <Button
+ variant="ghost"
+ size="sm"
+ className={`p-1 h-auto ${hasMeeting ? 'text-blue-600' : 'text-gray-400'}`}
+ onClick={() => hasMeeting && setRowAction({ row, type: "specification_meeting" })}
+ disabled={!hasMeeting}
+ >
+ {hasMeeting ? 'Yes' : 'No'}
+ </Button>
+ )
+ },
+ size: 100,
+ meta: { excelHeader: "사양설명회" },
},
- // ═══════════════════════════════════════════════════════════════
- // 가격 정보
- // ═══════════════════════════════════════════════════════════════
- {
- header: "가격 정보",
- columns: [
- {
- accessorKey: "currency",
- header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="통화" />,
- cell: ({ row }) => (
- <span className="font-mono text-sm">{row.original.currency}</span>
- ),
- size: 60,
- meta: { excelHeader: "통화" },
- },
+ // ░░░ 등록자 ░░░
- {
- accessorKey: "budget",
- header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="예산" />,
- cell: ({ row }) => (
- <span className="text-sm font-medium">
- {row.original.budget}
- </span>
- ),
- size: 120,
- meta: { excelHeader: "예산" },
- },
-
- {
- accessorKey: "targetPrice",
- header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="내정가" />,
- cell: ({ row }) => (
- <span className="text-sm font-medium text-orange-600">
- {row.original.targetPrice}
- </span>
- ),
- size: 120,
- meta: { excelHeader: "내정가" },
- },
-
- {
- accessorKey: "finalBidPrice",
- header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="최종입찰가" />,
- cell: ({ row }) => (
- <span className="text-sm font-medium text-green-600">
- {row.original.finalBidPrice}
- </span>
- ),
- size: 120,
- meta: { excelHeader: "최종입찰가" },
- },
- ]
+ {
+ accessorKey: "updatedBy",
+ header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="등록자" />,
+ cell: ({ row }) => (
+ <span className="text-sm">{row.original.updatedBy || '-'}</span>
+ ),
+ size: 100,
+ meta: { excelHeader: "등록자" },
},
-
- // ═══════════════════════════════════════════════════════════════
- // 참여 현황
- // ═══════════════════════════════════════════════════════════════
+ // 등록일시
{
- header: "참여 현황",
- columns: [
- {
- id: "participantExpected",
- header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="참여예정" />,
- cell: ({ row }) => (
- <Badge variant="outline" className="font-mono">
- {row.original.participantExpected}
- </Badge>
- ),
- size: 80,
- meta: { excelHeader: "참여예정" },
- },
-
- {
- id: "participantParticipated",
- header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="참여" />,
- cell: ({ row }) => (
- <Badge variant="default" className="font-mono">
- {row.original.participantParticipated}
- </Badge>
- ),
- size: 60,
- meta: { excelHeader: "참여" },
- },
-
- {
- id: "participantDeclined",
- header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="포기" />,
- cell: ({ row }) => (
- <Badge variant="destructive" className="font-mono">
- {row.original.participantDeclined}
- </Badge>
- ),
- size: 60,
- meta: { excelHeader: "포기" },
- },
- ]
+ accessorKey: "updatedAt",
+ header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="등록일시" />,
+ cell: ({ row }) => (
+ <span className="text-sm">{formatDate(row.original.updatedAt, "KR")}</span>
+ ),
+ size: 100,
+ meta: { excelHeader: "등록일시" },
},
-
// ═══════════════════════════════════════════════════════════════
// PR 정보
// ═══════════════════════════════════════════════════════════════
- {
- header: "PR 정보",
- columns: [
- {
- accessorKey: "prNumber",
- header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="PR No." />,
- cell: ({ row }) => (
- <span className="font-mono text-sm">{row.original.prNumber || '-'}</span>
- ),
- size: 100,
- meta: { excelHeader: "PR No." },
- },
-
- {
- accessorKey: "hasPrDocument",
- header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="PR 문서" />,
- cell: ({ row }) => {
- const hasPrDoc = row.original.hasPrDocument
+ // {
+ // header: "PR 정보",
+ // columns: [
+ // {
+ // accessorKey: "prNumber",
+ // header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="PR No." />,
+ // cell: ({ row }) => (
+ // <span className="font-mono text-sm">{row.original.prNumber || '-'}</span>
+ // ),
+ // size: 100,
+ // meta: { excelHeader: "PR No." },
+ // },
+
+ // {
+ // accessorKey: "hasPrDocument",
+ // header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="PR 문서" />,
+ // cell: ({ row }) => {
+ // const hasPrDoc = row.original.hasPrDocument
- return (
- <Button
- variant="ghost"
- size="sm"
- className={`p-1 h-auto ${hasPrDoc ? 'text-blue-600' : 'text-gray-400'}`}
- onClick={() => hasPrDoc && setRowAction({ row, type: "pr_documents" })}
- disabled={!hasPrDoc}
- >
- {hasPrDoc ? 'Yes' : 'No'}
- </Button>
- )
- },
- size: 80,
- meta: { excelHeader: "PR 문서" },
- },
- ]
- },
-
- // ═══════════════════════════════════════════════════════════════
- // 메타 정보
- // ═══════════════════════════════════════════════════════════════
- {
- header: "메타 정보",
- columns: [
- {
- accessorKey: "preQuoteDate",
- header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="사전견적일" />,
- cell: ({ row }) => (
- <span className="text-sm">{formatDate(row.original.preQuoteDate, "KR")}</span>
- ),
- size: 90,
- meta: { excelHeader: "사전견적일" },
- },
-
- {
- accessorKey: "biddingRegistrationDate",
- header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="입찰등록일" />,
- cell: ({ row }) => (
- <span className="text-sm">{formatDate(row.original.biddingRegistrationDate , "KR")}</span>
- ),
- size: 100,
- meta: { excelHeader: "입찰등록일" },
- },
-
- {
- accessorKey: "updatedAt",
- header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="최종수정일" />,
- cell: ({ row }) => (
- <span className="text-sm">{formatDate(row.original.updatedAt, "KR")}</span>
- ),
- size: 100,
- meta: { excelHeader: "최종수정일" },
- },
-
- {
- accessorKey: "updatedBy",
- header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="최종수정자" />,
- cell: ({ row }) => (
- <span className="text-sm">{row.original.updatedBy || '-'}</span>
- ),
- size: 100,
- meta: { excelHeader: "최종수정자" },
- },
- ]
- },
+ // return (
+ // <Button
+ // variant="ghost"
+ // size="sm"
+ // className={`p-1 h-auto ${hasPrDoc ? 'text-blue-600' : 'text-gray-400'}`}
+ // onClick={() => hasPrDoc && setRowAction({ row, type: "pr_documents" })}
+ // disabled={!hasPrDoc}
+ // >
+ // {hasPrDoc ? 'Yes' : 'No'}
+ // </Button>
+ // )
+ // },
+ // size: 80,
+ // meta: { excelHeader: "PR 문서" },
+ // },
+ // ]
+ // },
// ░░░ 비고 ░░░
{
@@ -611,6 +399,17 @@ export function getBiddingsColumns({ setRowAction }: GetColumnsProps): ColumnDef
<span className="text-xs text-muted-foreground ml-2">(수정 불가)</span>
)}
</DropdownMenuItem>
+ <DropdownMenuSeparator />
+ <DropdownMenuItem
+ onClick={() => setRowAction({ row, type: "bid_closure" })}
+ disabled={row.original.status !== 'bidding_disposal'}
+ >
+ <FileX className="mr-2 h-4 w-4" />
+ 폐찰하기
+ {row.original.status !== 'bidding_disposal' && (
+ <span className="text-xs text-muted-foreground ml-2">(유찰 시에만 가능)</span>
+ )}
+ </DropdownMenuItem>
{/* <DropdownMenuSeparator />
<DropdownMenuItem onClick={() => setRowAction({ row, type: "copy" })}>
<Package className="mr-2 h-4 w-4" />
diff --git a/lib/bidding/list/biddings-table-toolbar-actions.tsx b/lib/bidding/list/biddings-table-toolbar-actions.tsx
index 702396ae..0cb87b11 100644
--- a/lib/bidding/list/biddings-table-toolbar-actions.tsx
+++ b/lib/bidding/list/biddings-table-toolbar-actions.tsx
@@ -3,10 +3,9 @@
import * as React from "react"
import { type Table } from "@tanstack/react-table"
import {
- Plus, Send, Download, FileSpreadsheet
+ Send, Download, FileSpreadsheet
} from "lucide-react"
import { toast } from "sonner"
-import { useRouter } from "next/navigation"
import { useSession } from "next-auth/react"
import { exportTableToExcel } from "@/lib/export"
import { Button } from "@/components/ui/button"
@@ -14,32 +13,74 @@ import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
- DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu"
import { BiddingListItem } from "@/db/schema"
-import { CreateBiddingDialog } from "./create-bidding-dialog"
+// import { CreateBiddingDialog } from "./create-bidding-dialog"
import { TransmissionDialog } from "./biddings-transmission-dialog"
+import { BiddingCreateDialog } from "@/components/bidding/create/bidding-create-dialog"
+import { useForm } from "react-hook-form"
+import { zodResolver } from "@hookform/resolvers/zod"
+import { createBiddingSchema } from "@/lib/bidding/validation"
interface BiddingsTableToolbarActionsProps {
table: Table<BiddingListItem>
}
export function BiddingsTableToolbarActions({ table }: BiddingsTableToolbarActionsProps) {
- const router = useRouter()
const { data: session } = useSession()
const [isExporting, setIsExporting] = React.useState(false)
const [isTransmissionDialogOpen, setIsTransmissionDialogOpen] = React.useState(false)
const userId = session?.user?.id ? Number(session.user.id) : 1
+ // 입찰 생성 폼
+ const form = useForm({
+ resolver: zodResolver(createBiddingSchema),
+ defaultValues: {
+ revision: 0,
+ title: '',
+ description: '',
+ content: '',
+ noticeType: 'standard' as const,
+ contractType: 'general' as const,
+ biddingType: 'equipment' as const,
+ awardCount: 'single' as const,
+ currency: 'KRW',
+ status: 'bidding_generated' as const,
+ bidPicName: '',
+ bidPicCode: '',
+ supplyPicName: '',
+ supplyPicCode: '',
+ requesterName: '',
+ attachments: [],
+ vendorAttachments: [],
+ hasSpecificationMeeting: false,
+ hasPrDocument: false,
+ isPublic: false,
+ isUrgent: false,
+ purchasingOrganization: '',
+ biddingConditions: {
+ paymentTerms: '',
+ taxConditions: 'V1',
+ incoterms: 'DAP',
+ incotermsOption: '',
+ contractDeliveryDate: '',
+ shippingPort: '',
+ destinationPort: '',
+ isPriceAdjustmentApplicable: false,
+ sparePartOptions: '',
+ },
+ },
+ })
+
// 선택된 입찰들
const selectedBiddings = React.useMemo(() => {
return table
.getFilteredSelectedRowModel()
.rows
.map(row => row.original)
- }, [table.getFilteredSelectedRowModel().rows])
+ }, [table])
// 업체선정이 완료된 입찰만 전송 가능
const canTransmit = selectedBiddings.length === 1 && selectedBiddings[0].status === 'vendor_selected'
@@ -52,19 +93,22 @@ export function BiddingsTableToolbarActions({ table }: BiddingsTableToolbarActio
excludeColumns: ["select", "actions"],
})
toast.success("입찰 목록이 성공적으로 내보내졌습니다.")
- } catch (error) {
+ } catch {
toast.error("내보내기 중 오류가 발생했습니다.")
} finally {
setIsExporting(false)
}
}
+
return (
<>
<div className="flex items-center gap-2">
- {/* 신규 생성 */}
- <CreateBiddingDialog
- />
+ {/* 신규입찰 생성 버튼 */}
+ <BiddingCreateDialog form={form} onSuccess={() => {
+ // 성공 시 테이블 새로고침 등 추가 작업
+ // window.location.reload()
+ }} />
{/* 전송하기 (업체선정 완료된 입찰만) */}
<Button
diff --git a/lib/bidding/list/biddings-table.tsx b/lib/bidding/list/biddings-table.tsx
index 8920d9db..39952d5a 100644
--- a/lib/bidding/list/biddings-table.tsx
+++ b/lib/bidding/list/biddings-table.tsx
@@ -2,6 +2,7 @@
import * as React from "react"
import { useRouter } from "next/navigation"
+import { useSession } from "next-auth/react"
import type {
DataTableAdvancedFilterField,
DataTableFilterField,
@@ -22,7 +23,7 @@ import {
biddingTypeLabels
} from "@/db/schema"
import { EditBiddingSheet } from "./edit-bidding-sheet"
-import { SpecificationMeetingDialog, PrDocumentsDialog } from "./bidding-detail-dialogs"
+import { SpecificationMeetingDialog, PrDocumentsDialog, BidClosureDialog } from "./bidding-detail-dialogs"
interface BiddingsTableProps {
@@ -43,6 +44,7 @@ export function BiddingsTable({ promises }: BiddingsTableProps) {
const [isCompact, setIsCompact] = React.useState<boolean>(false)
const [specMeetingDialogOpen, setSpecMeetingDialogOpen] = React.useState(false)
const [prDocumentsDialogOpen, setPrDocumentsDialogOpen] = React.useState(false)
+ const [bidClosureDialogOpen, setBidClosureDialogOpen] = React.useState(false)
const [selectedBidding, setSelectedBidding] = React.useState<BiddingListItemWithManagerCode | null>(null)
console.log(data,"data")
@@ -50,6 +52,7 @@ export function BiddingsTable({ promises }: BiddingsTableProps) {
const [rowAction, setRowAction] = React.useState<DataTableRowAction<BiddingListItemWithManagerCode> | null>(null)
const router = useRouter()
+ const { data: session } = useSession()
const columns = React.useMemo(
() => getBiddingsColumns({ setRowAction }),
@@ -63,8 +66,8 @@ export function BiddingsTable({ promises }: BiddingsTableProps) {
switch (rowAction.type) {
case "view":
- // 상세 페이지로 이동
- router.push(`/evcp/bid/${rowAction.row.original.id}`)
+ // 상세 페이지로 이동 (info 페이지로)
+ router.push(`/evcp/bid/${rowAction.row.original.id}/info`)
break
case "update":
// EditBiddingSheet는 아래에서 별도로 처리
@@ -75,6 +78,9 @@ export function BiddingsTable({ promises }: BiddingsTableProps) {
case "pr_documents":
setPrDocumentsDialogOpen(true)
break
+ case "bid_closure":
+ setBidClosureDialogOpen(true)
+ break
// 기존 다른 액션들은 그대로 유지
default:
break
@@ -88,10 +94,10 @@ export function BiddingsTable({ promises }: BiddingsTableProps) {
{ id: "title", label: "입찰명", type: "text" },
{ id: "biddingNumber", label: "입찰번호", type: "text" },
{ id: "projectName", label: "프로젝트명", type: "text" },
- { id: "managerName", label: "담당자", type: "text" },
+ { id: "bidPicName", label: "입찰담당자", type: "text" },
{
id: "status",
- label: "입찰상태",
+ label: "진행상태",
type: "multi-select",
options: Object.entries(biddingStatusLabels).map(([value, label]) => ({
label,
@@ -154,6 +160,12 @@ export function BiddingsTable({ promises }: BiddingsTableProps) {
setSelectedBidding(null)
}, [])
+ const handleBidClosureDialogClose = React.useCallback(() => {
+ setBidClosureDialogOpen(false)
+ setRowAction(null)
+ setSelectedBidding(null)
+ }, [])
+
return (
<>
@@ -195,6 +207,14 @@ export function BiddingsTable({ promises }: BiddingsTableProps) {
onOpenChange={handlePrDocumentsDialogClose}
bidding={selectedBidding}
/>
+
+ {/* 폐찰하기 다이얼로그 */}
+ <BidClosureDialog
+ open={bidClosureDialogOpen}
+ onOpenChange={handleBidClosureDialogClose}
+ bidding={selectedBidding}
+ userId={session?.user?.id ? String(session.user.id) : ''}
+ />
</>
)
diff --git a/lib/bidding/list/create-bidding-dialog.tsx b/lib/bidding/list/create-bidding-dialog.tsx
index 50246f58..20ea740f 100644
--- a/lib/bidding/list/create-bidding-dialog.tsx
+++ b/lib/bidding/list/create-bidding-dialog.tsx
@@ -1,2242 +1,2114 @@
-"use client"
+'use client'
-import * as React from "react"
-import { useRouter } from "next/navigation"
-import { useForm } from "react-hook-form"
-import { zodResolver } from "@hookform/resolvers/zod"
-import { Loader2, Plus, Trash2, FileText, Paperclip, CheckCircle2, ChevronRight, ChevronLeft } from "lucide-react"
-import { toast } from "sonner"
-import { useSession } from "next-auth/react"
-
-import { Button } from "@/components/ui/button"
-import {
- Dialog,
- DialogContent,
- DialogTrigger,
- DialogDescription,
- DialogHeader,
- DialogTitle,
-} from "@/components/ui/dialog"
-import {
- Form,
- FormControl,
- FormField,
- FormItem,
- FormLabel,
- FormMessage,
- FormDescription,
-} from "@/components/ui/form"
-import {
- Select,
- SelectContent,
- SelectItem,
- SelectTrigger,
- SelectValue,
-} from "@/components/ui/select"
-import { Input } from "@/components/ui/input"
-import { Textarea } from "@/components/ui/textarea"
-import { Switch } from "@/components/ui/switch"
-import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
-import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
+import * as React from 'react'
+import { useRouter } from 'next/navigation'
+import { useForm } from 'react-hook-form'
+import { zodResolver } from '@hookform/resolvers/zod'
import {
- Table,
- TableBody,
- TableCell,
- TableHead,
- TableHeader,
- TableRow,
-} from "@/components/ui/table"
+ Loader2,
+ Plus,
+ Trash2,
+ FileText,
+ Paperclip,
+ ChevronRight,
+ ChevronLeft,
+ X,
+} from 'lucide-react'
+import { toast } from 'sonner'
+import { useSession } from 'next-auth/react'
+
+import { Button } from '@/components/ui/button'
import {
- Dropzone,
- DropzoneDescription,
- DropzoneInput,
- DropzoneTitle,
- DropzoneUploadIcon,
- DropzoneZone,
-} from "@/components/ui/dropzone"
+ Dialog,
+ DialogContent,
+ DialogTrigger,
+ DialogDescription,
+ DialogHeader,
+ DialogTitle,
+} from '@/components/ui/dialog'
import {
- FileList,
- FileListAction,
- FileListDescription,
- FileListHeader,
- FileListIcon,
- FileListInfo,
- FileListItem,
- FileListName,
- FileListSize,
-} from "@/components/ui/file-list"
-import { Checkbox } from "@/components/ui/checkbox"
-
-import { createBidding, type CreateBiddingInput } from "@/lib/bidding/service"
-import { getIncotermsForSelection, getPaymentTermsForSelection, getPlaceOfShippingForSelection, getPlaceOfDestinationForSelection } from "@/lib/procurement-select/service"
-import { TAX_CONDITIONS } from "@/lib/tax-conditions/types"
+ Form,
+ FormControl,
+ FormDescription,
+ FormField,
+ FormItem,
+ FormLabel,
+ FormMessage,
+} from '@/components/ui/form'
import {
- createBiddingSchema,
- type CreateBiddingSchema
-} from "@/lib/bidding/validation"
+ Select,
+ SelectContent,
+ SelectItem,
+ SelectTrigger,
+ SelectValue,
+} from '@/components/ui/select'
+import { Input } from '@/components/ui/input'
+import { Textarea } from '@/components/ui/textarea'
+import { Switch } from '@/components/ui/switch'
+import { Label } from '@/components/ui/label'
+import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
+import { Tabs, TabsContent } from '@/components/ui/tabs'
+import { Checkbox } from '@/components/ui/checkbox'
+
+import { createBidding } from '@/lib/bidding/service'
import {
- biddingStatusLabels,
- contractTypeLabels,
- biddingTypeLabels,
- awardCountLabels
-} from "@/db/schema"
-import { ProjectSelector } from "@/components/ProjectSelector"
-import {
- AlertDialog,
- AlertDialogAction,
- AlertDialogCancel,
- AlertDialogContent,
- AlertDialogDescription,
- AlertDialogFooter,
- AlertDialogHeader,
- AlertDialogTitle,
-} from "@/components/ui/alert-dialog"
+ getIncotermsForSelection,
+ getPaymentTermsForSelection,
+ getPlaceOfShippingForSelection,
+ getPlaceOfDestinationForSelection,
+} from '@/lib/procurement-select/service'
+import { TAX_CONDITIONS } from '@/lib/tax-conditions/types'
+import { createBiddingSchema, type CreateBiddingSchema } from '@/lib/bidding/validation'
+import { contractTypeLabels, biddingTypeLabels, awardCountLabels } from '@/db/schema'
+import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle } from '@/components/ui/alert-dialog'
+import { cn } from '@/lib/utils'
+import { MaterialGroupSingleSelector } from '@/components/common/material/material-group-single-selector'
+import { MaterialSingleSelector } from '@/components/common/selectors/material/material-single-selector'
+import { PurchaseGroupCodeSelector } from '@/components/common/selectors/purchase-group-code/purchase-group-code-selector'
+import { ProcurementManagerSelector } from '@/components/common/selectors/procurement-manager/procurement-manager-selector'
+import { WbsCodeSingleSelector } from '@/components/common/selectors/wbs-code/wbs-code-single-selector'
+import { CostCenterSingleSelector } from '@/components/common/selectors/cost-center/cost-center-single-selector'
+import { GlAccountSingleSelector } from '@/components/common/selectors/gl-account/gl-account-single-selector'
+import { MaterialGroupSelectorDialogSingle } from '@/components/common/material/material-group-selector-dialog-single'
+import { MaterialSelectorDialogSingle } from '@/components/common/selectors/material/material-selector-dialog-single'
+import { ProjectSelector } from '@/components/ProjectSelector'
// 사양설명회 정보 타입
interface SpecificationMeetingInfo {
- meetingDate: string
- meetingTime: string
- location: string
- address: string
- contactPerson: string
- contactPhone: string
- contactEmail: string
- agenda: string
- materials: string
- notes: string
- isRequired: boolean
- meetingFiles: File[] // 사양설명회 첨부파일
+ meetingDate: string
+ meetingTime: string
+ location: string
+ address: string
+ contactPerson: string
+ contactPhone: string
+ contactEmail: string
+ agenda: string
+ materials: string
+ notes: string
+ isRequired: boolean
+ meetingFiles: File[] // 사양설명회 첨부파일
}
// PR 아이템 정보 타입
interface PRItemInfo {
- id: string // 임시 ID for UI
- prNumber: string
- itemCode: string // 기존 itemNumber에서 변경
- itemInfo: string
- quantity: string
- quantityUnit: string
- totalWeight: string
- weightUnit: string
- materialDescription: string
- hasSpecDocument: boolean
- requestedDeliveryDate: string
- specFiles: File[]
- isRepresentative: boolean // 대표 아이템 여부
+ id: string // 임시 ID for UI
+ prNumber: string
+ projectId?: number // 프로젝트 ID 추가
+ projectInfo?: string // 프로젝트 정보 (기존 호환성 유지)
+ shi?: string // SHI 정보 추가
+ quantity: string
+ quantityUnit: string
+ totalWeight: string
+ weightUnit: string
+ materialDescription: string
+ hasSpecDocument: boolean
+ requestedDeliveryDate: string
+ specFiles: File[]
+ isRepresentative: boolean // 대표 아이템 여부
+ // 가격 정보
+ annualUnitPrice: string
+ currency: string
+ // 자재 그룹 정보 (필수)
+ materialGroupNumber: string // 자재그룹코드 - 필수
+ materialGroupInfo: string // 자재그룹명 - 필수
+ // 자재 정보
+ materialNumber: string // 자재코드
+ materialInfo: string // 자재명
+ // 단위 정보
+ priceUnit: string // 가격단위
+ purchaseUnit: string // 구매단위
+ materialWeight: string // 자재순중량
+ // WBS 정보
+ wbsCode: string // WBS 코드
+ wbsName: string // WBS 명칭
+ // Cost Center 정보
+ costCenterCode: string // 코스트센터 코드
+ costCenterName: string // 코스트센터 명칭
+ // GL Account 정보
+ glAccountCode: string // GL 계정 코드
+ glAccountName: string // GL 계정 명칭
+ // 내정 정보
+ targetUnitPrice: string
+ targetAmount: string
+ targetCurrency: string
+ // 예산 정보
+ budgetAmount: string
+ budgetCurrency: string
+ // 실적 정보
+ actualAmount: string
+ actualCurrency: string
}
-// 탭 순서 정의
-const TAB_ORDER = ["basic", "schedule", "details", "manager"] as const
+const TAB_ORDER = ['basic', 'schedule', 'details', 'manager'] as const
type TabType = typeof TAB_ORDER[number]
export function CreateBiddingDialog() {
- const router = useRouter()
- const [isSubmitting, setIsSubmitting] = React.useState(false)
- const { data: session } = useSession()
- const [open, setOpen] = React.useState(false)
- const [activeTab, setActiveTab] = React.useState<TabType>("basic")
- const [showSuccessDialog, setShowSuccessDialog] = React.useState(false) // 추가
- const [createdBiddingId, setCreatedBiddingId] = React.useState<number | null>(null) // 추가
- const [showCloseConfirmDialog, setShowCloseConfirmDialog] = React.useState(false) // 닫기 확인 다이얼로그 상태
-
- // Procurement 데이터 상태들
- const [paymentTermsOptions, setPaymentTermsOptions] = React.useState<Array<{code: string, description: string}>>([])
- const [incotermsOptions, setIncotermsOptions] = React.useState<Array<{code: string, description: string}>>([])
- const [shippingPlaces, setShippingPlaces] = React.useState<Array<{code: string, description: string}>>([])
- const [destinationPlaces, setDestinationPlaces] = React.useState<Array<{code: string, description: string}>>([])
- const [procurementLoading, setProcurementLoading] = React.useState(false)
-
- // 사양설명회 정보 상태
- const [specMeetingInfo, setSpecMeetingInfo] = React.useState<SpecificationMeetingInfo>({
- meetingDate: "",
- meetingTime: "",
- location: "",
- address: "",
- contactPerson: "",
- contactPhone: "",
- contactEmail: "",
- agenda: "",
- materials: "",
- notes: "",
- isRequired: false,
- meetingFiles: [], // 사양설명회 첨부파일
- })
-
- // PR 아이템들 상태 - 기본적으로 하나의 빈 아이템 생성
- const [prItems, setPrItems] = React.useState<PRItemInfo[]>([
- {
- id: `pr-default`,
- prNumber: "",
- itemCode: "",
- itemInfo: "",
- quantity: "",
- quantityUnit: "EA",
- totalWeight: "",
- weightUnit: "KG",
- materialDescription: "",
- hasSpecDocument: false,
- requestedDeliveryDate: "",
- specFiles: [],
- isRepresentative: true, // 첫 번째 아이템은 대표 아이템
- }
- ])
-
- // 파일 첨부를 위해 선택된 아이템 ID
- const [selectedItemForFile, setSelectedItemForFile] = React.useState<string | null>(null)
-
- // 입찰 조건 상태 (기본값 설정 포함)
- const [biddingConditions, setBiddingConditions] = React.useState({
- paymentTerms: "", // 초기값 빈값, 데이터 로드 후 설정
- taxConditions: "", // 초기값 빈값, 데이터 로드 후 설정
- incoterms: "", // 초기값 빈값, 데이터 로드 후 설정
- contractDeliveryDate: "",
- shippingPort: "",
- destinationPort: "",
- isPriceAdjustmentApplicable: false,
- sparePartOptions: "",
+ const router = useRouter()
+ const [isSubmitting, setIsSubmitting] = React.useState(false)
+ const { data: session } = useSession()
+ const [open, setOpen] = React.useState(false)
+ const [activeTab, setActiveTab] = React.useState<TabType>('basic')
+ const [showSuccessDialog, setShowSuccessDialog] = React.useState(false)
+ const [createdBiddingId, setCreatedBiddingId] = React.useState<number | null>(null)
+ const [showCloseConfirmDialog, setShowCloseConfirmDialog] = React.useState(false)
+
+ const [paymentTermsOptions, setPaymentTermsOptions] = React.useState<
+ Array<{ code: string; description: string }>
+ >([])
+ const [incotermsOptions, setIncotermsOptions] = React.useState<
+ Array<{ code: string; description: string }>
+ >([])
+ const [shippingPlaces, setShippingPlaces] = React.useState<
+ Array<{ code: string; description: string }>
+ >([])
+ const [destinationPlaces, setDestinationPlaces] = React.useState<
+ Array<{ code: string; description: string }>
+ >([])
+
+ const [specMeetingInfo, setSpecMeetingInfo] =
+ React.useState<SpecificationMeetingInfo>({
+ meetingDate: '',
+ meetingTime: '',
+ location: '',
+ address: '',
+ contactPerson: '',
+ contactPhone: '',
+ contactEmail: '',
+ agenda: '',
+ materials: '',
+ notes: '',
+ isRequired: false,
+ meetingFiles: [],
})
- // Procurement 데이터 로드 함수들
- const loadPaymentTerms = React.useCallback(async () => {
- setProcurementLoading(true);
- try {
- const data = await getPaymentTermsForSelection();
- setPaymentTermsOptions(data);
- // 기본값 설정 로직: P008이 있으면 P008로, 없으면 첫 번째 항목으로 설정
- const setDefaultPaymentTerms = () => {
- const p008Exists = data.some(item => item.code === "P008");
- if (p008Exists) {
- setBiddingConditions(prev => ({ ...prev, paymentTerms: "P008" }));
- }
- };
-
- setDefaultPaymentTerms();
- } catch (error) {
- console.error("Failed to load payment terms:", error);
- toast.error("결제조건 목록을 불러오는데 실패했습니다.");
- // 에러 시 기본값 초기화
- if (biddingConditions.paymentTerms === "P008") {
- setBiddingConditions(prev => ({ ...prev, paymentTerms: "" }));
- }
- } finally {
- setProcurementLoading(false);
- }
- }, [biddingConditions.paymentTerms]);
-
- const loadIncoterms = React.useCallback(async () => {
- setProcurementLoading(true);
- try {
- const data = await getIncotermsForSelection();
- setIncotermsOptions(data);
-
- // 기본값 설정 로직: DAP가 있으면 DAP로, 없으면 첫 번째 항목으로 설정
- const setDefaultIncoterms = () => {
- const dapExists = data.some(item => item.code === "DAP");
- if (dapExists) {
- setBiddingConditions(prev => ({ ...prev, incoterms: "DAP" }));
- }
- };
-
- setDefaultIncoterms();
- } catch (error) {
- console.error("Failed to load incoterms:", error);
- toast.error("운송조건 목록을 불러오는데 실패했습니다.");
- } finally {
- setProcurementLoading(false);
- }
- }, [biddingConditions.incoterms]);
-
- const loadShippingPlaces = React.useCallback(async () => {
- setProcurementLoading(true);
- try {
- const data = await getPlaceOfShippingForSelection();
- setShippingPlaces(data);
- } catch (error) {
- console.error("Failed to load shipping places:", error);
- toast.error("선적지 목록을 불러오는데 실패했습니다.");
- } finally {
- setProcurementLoading(false);
- }
- }, []);
-
- const loadDestinationPlaces = React.useCallback(async () => {
- setProcurementLoading(true);
- try {
- const data = await getPlaceOfDestinationForSelection();
- setDestinationPlaces(data);
- } catch (error) {
- console.error("Failed to load destination places:", error);
- toast.error("하역지 목록을 불러오는데 실패했습니다.");
- } finally {
- setProcurementLoading(false);
- }
- }, []);
-
- // 다이얼로그 열릴 때 procurement 데이터 로드 및 기본값 설정
- React.useEffect(() => {
- if (open) {
- loadPaymentTerms();
- loadIncoterms();
- loadShippingPlaces();
- loadDestinationPlaces();
-
- // 세금조건 기본값 설정 (V1이 있는지 확인하고 설정)
- const v1Exists = TAX_CONDITIONS.some(item => item.code === "V1");
- if (v1Exists) {
- setBiddingConditions(prev => ({ ...prev, taxConditions: "V1" }));
- }
- }
- }, [open, loadPaymentTerms, loadIncoterms, loadShippingPlaces, loadDestinationPlaces])
-
-
- // 사양설명회 파일 추가
- const addMeetingFiles = (files: File[]) => {
- setSpecMeetingInfo(prev => ({
- ...prev,
- meetingFiles: [...prev.meetingFiles, ...files]
- }))
+ const [prItems, setPrItems] = React.useState<PRItemInfo[]>([
+ {
+ id: `pr-default`,
+ prNumber: '',
+ projectId: undefined,
+ projectInfo: '',
+ shi: '',
+ quantity: '',
+ quantityUnit: 'EA',
+ totalWeight: '',
+ weightUnit: 'KG',
+ materialDescription: '',
+ hasSpecDocument: false,
+ requestedDeliveryDate: '',
+ specFiles: [],
+ isRepresentative: true,
+ annualUnitPrice: '',
+ currency: 'KRW',
+ materialGroupNumber: '',
+ materialGroupInfo: '',
+ materialNumber: '',
+ materialInfo: '',
+ priceUnit: '',
+ purchaseUnit: '1',
+ materialWeight: '',
+ wbsCode: '',
+ wbsName: '',
+ costCenterCode: '',
+ costCenterName: '',
+ glAccountCode: '',
+ glAccountName: '',
+ targetUnitPrice: '',
+ targetAmount: '',
+ targetCurrency: 'KRW',
+ budgetAmount: '',
+ budgetCurrency: 'KRW',
+ actualAmount: '',
+ actualCurrency: 'KRW',
+ },
+ ])
+ const [selectedItemForFile, setSelectedItemForFile] = React.useState<string | null>(null)
+ const [quantityWeightMode, setQuantityWeightMode] = React.useState<'quantity' | 'weight'>('quantity')
+ const [costCenterDialogOpen, setCostCenterDialogOpen] = React.useState(false)
+ const [glAccountDialogOpen, setGlAccountDialogOpen] = React.useState(false)
+ const [wbsCodeDialogOpen, setWbsCodeDialogOpen] = React.useState(false)
+ const [materialGroupDialogOpen, setMaterialGroupDialogOpen] = React.useState(false)
+ const [materialDialogOpen, setMaterialDialogOpen] = React.useState(false)
+ const [biddingConditions, setBiddingConditions] = React.useState({
+ paymentTerms: '',
+ taxConditions: '',
+ incoterms: '',
+ incotermsOption: '',
+ contractDeliveryDate: '',
+ shippingPort: '',
+ destinationPort: '',
+ isPriceAdjustmentApplicable: false,
+ sparePartOptions: '',
+ })
+
+ // -- 데이터 로딩 및 상태 동기화 로직
+ const loadPaymentTerms = React.useCallback(async () => {
+ try {
+ const data = await getPaymentTermsForSelection()
+ setPaymentTermsOptions(data)
+ const p008Exists = data.some((item) => item.code === 'P008')
+ if (p008Exists) {
+ setBiddingConditions((prev) => ({ ...prev, paymentTerms: 'P008' }))
+ }
+ } catch (error) {
+ console.error('Failed to load payment terms:', error)
+ toast.error('결제조건 목록을 불러오는데 실패했습니다.')
}
-
- // 사양설명회 파일 제거
- const removeMeetingFile = (fileIndex: number) => {
- setSpecMeetingInfo(prev => ({
- ...prev,
- meetingFiles: prev.meetingFiles.filter((_, index) => index !== fileIndex)
- }))
+ }, [])
+
+ const loadIncoterms = React.useCallback(async () => {
+ try {
+ const data = await getIncotermsForSelection()
+ setIncotermsOptions(data)
+ const dapExists = data.some((item) => item.code === 'DAP')
+ if (dapExists) {
+ setBiddingConditions((prev) => ({ ...prev, incoterms: 'DAP' }))
+ }
+ } catch (error) {
+ console.error('Failed to load incoterms:', error)
+ toast.error('운송조건 목록을 불러오는데 실패했습니다.')
}
-
- // PR 문서 첨부 여부 자동 계산
- const hasPrDocuments = React.useMemo(() => {
- return prItems.some(item => item.prNumber.trim() !== "" || item.specFiles.length > 0)
- }, [prItems])
-
- const form = useForm<CreateBiddingSchema>({
- resolver: zodResolver(createBiddingSchema),
- defaultValues: {
- revision: 0,
- projectId: 0, // 임시 기본값, validation에서 체크
- projectName: "",
- itemName: "",
- title: "",
- description: "",
- content: "",
-
- contractType: "general",
- biddingType: "equipment",
- biddingTypeCustom: "",
- awardCount: "single",
- contractStartDate: "",
- contractEndDate: "",
-
- submissionStartDate: "",
- submissionEndDate: "",
-
- hasSpecificationMeeting: false,
- prNumber: "",
-
- currency: "KRW",
- budget: "",
- targetPrice: "",
- finalBidPrice: "",
-
- status: "bidding_generated",
- isPublic: false,
- managerName: "",
- managerEmail: "",
- managerPhone: "",
-
- remarks: "",
- },
- })
-
- // 현재 탭 인덱스 계산
- const currentTabIndex = TAB_ORDER.indexOf(activeTab)
- const isLastTab = currentTabIndex === TAB_ORDER.length - 1
- const isFirstTab = currentTabIndex === 0
-
- // 다음/이전 탭으로 이동
- const goToNextTab = () => {
- if (!isLastTab) {
- setActiveTab(TAB_ORDER[currentTabIndex + 1])
- }
+ }, [])
+
+ const loadShippingPlaces = React.useCallback(async () => {
+ try {
+ const data = await getPlaceOfShippingForSelection()
+ setShippingPlaces(data)
+ } catch (error) {
+ console.error('Failed to load shipping places:', error)
+ toast.error('선적지 목록을 불러오는데 실패했습니다.')
}
-
- const goToPreviousTab = () => {
- if (!isFirstTab) {
- setActiveTab(TAB_ORDER[currentTabIndex - 1])
- }
+ }, [])
+
+ const loadDestinationPlaces = React.useCallback(async () => {
+ try {
+ const data = await getPlaceOfDestinationForSelection()
+ setDestinationPlaces(data)
+ } catch (error) {
+ console.error('Failed to load destination places:', error)
+ toast.error('하역지 목록을 불러오는데 실패했습니다.')
}
-
- // 탭별 validation 상태 체크
- const getTabValidationState = React.useCallback(() => {
- const formValues = form.getValues()
- const formErrors = form.formState.errors
-
- return {
- basic: {
- isValid: formValues.title.trim() !== "",
- hasErrors: !!(formErrors.title)
- },
- contract: {
- isValid: formValues.contractType &&
- formValues.biddingType &&
- formValues.awardCount &&
- formValues.contractStartDate &&
- formValues.contractEndDate &&
- formValues.currency,
- hasErrors: !!(formErrors.contractType || formErrors.biddingType || formErrors.awardCount || formErrors.contractStartDate || formErrors.contractEndDate || formErrors.currency)
- },
- schedule: {
- isValid: formValues.submissionStartDate &&
- formValues.submissionEndDate &&
- (!formValues.hasSpecificationMeeting ||
- (specMeetingInfo.meetingDate && specMeetingInfo.location && specMeetingInfo.contactPerson)),
- hasErrors: !!(formErrors.submissionStartDate || formErrors.submissionEndDate)
- },
- conditions: {
- isValid: biddingConditions.paymentTerms.trim() !== "" &&
- biddingConditions.taxConditions.trim() !== "" &&
- biddingConditions.incoterms.trim() !== "" &&
- biddingConditions.contractDeliveryDate.trim() !== "" &&
- biddingConditions.shippingPort.trim() !== "" &&
- biddingConditions.destinationPort.trim() !== "",
- hasErrors: false
- },
- details: {
- isValid: prItems.length > 0,
- hasErrors: false
- },
- manager: {
- isValid: true, // 담당자 정보는 자동 설정되므로 항상 유효
- hasErrors: !!(formErrors.managerName || formErrors.managerEmail || formErrors.managerPhone)
- }
- }
- }, [form, specMeetingInfo.meetingDate, specMeetingInfo.location, specMeetingInfo.contactPerson, biddingConditions])
-
- const tabValidation = getTabValidationState()
-
- // 현재 탭이 유효한지 확인
- const isCurrentTabValid = () => {
- const validation = tabValidation[activeTab as keyof typeof tabValidation]
- return validation?.isValid ?? true
+ }, [])
+
+ React.useEffect(() => {
+ if (open) {
+ loadPaymentTerms()
+ loadIncoterms()
+ loadShippingPlaces()
+ loadDestinationPlaces()
+ const v1Exists = TAX_CONDITIONS.some((item) => item.code === 'V1')
+ if (v1Exists) {
+ setBiddingConditions((prev) => ({ ...prev, taxConditions: 'V1' }))
+ }
}
-
- // 대표 PR 번호 자동 계산
- const representativePrNumber = React.useMemo(() => {
- const representativeItem = prItems.find(item => item.isRepresentative)
- return representativeItem?.prNumber || ""
- }, [prItems])
-
- // 대표 품목명 자동 계산 (첫 번째 PR 아이템의 itemInfo)
- const representativeItemName = React.useMemo(() => {
- const representativeItem = prItems.find(item => item.isRepresentative)
- return representativeItem?.itemInfo || ""
- }, [prItems])
-
- // hasPrDocument 필드와 prNumber, itemName을 자동으로 업데이트
- React.useEffect(() => {
- form.setValue("hasPrDocument", hasPrDocuments)
- form.setValue("prNumber", representativePrNumber)
- form.setValue("itemName", representativeItemName)
- }, [hasPrDocuments, representativePrNumber, representativeItemName, form])
-
-
-
- // 세션 정보로 담당자 정보 자동 채우기
- React.useEffect(() => {
- if (session?.user) {
- // 담당자명 설정
- if (session.user.name) {
- form.setValue("managerName", session.user.name)
- // 사양설명회 담당자도 동일하게 설정
- setSpecMeetingInfo(prev => ({
- ...prev,
- contactPerson: session.user.name || "",
- contactEmail: session.user.email || "",
- }))
- }
-
- // 담당자 이메일 설정
- if (session.user.email) {
- form.setValue("managerEmail", session.user.email)
- }
-
- // 담당자 전화번호는 세션에 있다면 설정 (보통 세션에 전화번호는 없지만, 있다면)
- if ('phone' in session.user && session.user.phone) {
- form.setValue("managerPhone", session.user.phone as string)
- }
- }
- }, [session, form])
-
- // PR 아이템 추가
- const addPRItem = () => {
- const newItem: PRItemInfo = {
- id: `pr-${Math.random().toString(36).substr(2, 9)}`,
- prNumber: "",
- itemCode: "",
- itemInfo: "",
- quantity: "",
- quantityUnit: "EA",
- totalWeight: "",
- weightUnit: "KG",
- materialDescription: "",
- hasSpecDocument: false,
- requestedDeliveryDate: "",
- specFiles: [],
- isRepresentative: prItems.length === 0, // 첫 번째 아이템은 자동으로 대표 아이템
- }
- setPrItems(prev => [...prev, newItem])
+ }, [open, loadPaymentTerms, loadIncoterms, loadShippingPlaces, loadDestinationPlaces])
+
+ const hasPrDocuments = React.useMemo(() => {
+ return prItems.some((item) => item.prNumber.trim() !== '' || item.specFiles.length > 0)
+ }, [prItems])
+
+ const form = useForm<CreateBiddingSchema>({
+ resolver: zodResolver(createBiddingSchema),
+ defaultValues: {
+ revision: 0,
+ projectId: 0,
+ projectName: '',
+ itemName: '',
+ title: '',
+ description: '',
+ content: '',
+ contractType: 'general',
+ biddingType: 'equipment',
+ biddingTypeCustom: '',
+ awardCount: 'single',
+ contractStartDate: '',
+ contractEndDate: '',
+ submissionStartDate: '',
+ submissionEndDate: '',
+ hasSpecificationMeeting: false,
+ prNumber: '',
+ currency: 'KRW',
+ status: 'bidding_generated',
+ isPublic: false,
+ purchasingOrganization: '',
+ managerName: '',
+ managerEmail: '',
+ managerPhone: '',
+ remarks: '',
+ },
+ })
+
+ const currentTabIndex = TAB_ORDER.indexOf(activeTab)
+ const isLastTab = currentTabIndex === TAB_ORDER.length - 1
+ const isFirstTab = currentTabIndex === 0
+
+ const goToNextTab = () => {
+ if (!isLastTab) {
+ setActiveTab(TAB_ORDER[currentTabIndex + 1])
}
+ }
- // PR 아이템 제거
- const removePRItem = (id: string) => {
- // 최소 하나의 아이템은 유지해야 함
- if (prItems.length <= 1) {
- toast.error("최소 하나의 품목이 필요합니다.")
- return
- }
-
- setPrItems(prev => {
- const filteredItems = prev.filter(item => item.id !== id)
- // 만약 대표 아이템을 삭제했다면, 첫 번째 아이템을 대표로 설정
- const removedItem = prev.find(item => item.id === id)
- if (removedItem?.isRepresentative && filteredItems.length > 0) {
- filteredItems[0].isRepresentative = true
- }
- return filteredItems
- })
- // 파일 첨부 중인 아이템이면 선택 해제
- if (selectedItemForFile === id) {
- setSelectedItemForFile(null)
- }
+ const goToPreviousTab = () => {
+ if (!isFirstTab) {
+ setActiveTab(TAB_ORDER[currentTabIndex - 1])
}
-
- // PR 아이템 업데이트
- const updatePRItem = (id: string, updates: Partial<PRItemInfo>) => {
- setPrItems(prev => prev.map(item =>
- item.id === id ? { ...item, ...updates } : item
- ))
+ }
+
+ const getTabValidationState = React.useCallback(() => {
+ const formValues = form.getValues()
+ const formErrors = form.formState.errors
+
+ return {
+ basic: {
+ isValid: formValues.title.trim() !== '',
+ hasErrors: !!formErrors.title,
+ },
+ schedule: {
+ isValid:
+ formValues.submissionStartDate &&
+ formValues.submissionEndDate &&
+ (!formValues.hasSpecificationMeeting ||
+ (specMeetingInfo.meetingDate && specMeetingInfo.location && specMeetingInfo.contactPerson)),
+ hasErrors: !!(formErrors.submissionStartDate || formErrors.submissionEndDate),
+ },
+ details: {
+ // 임시로 자재그룹코드 필수 체크 해제
+ // isValid: prItems.length > 0 && prItems.every(item => item.materialGroupNumber.trim() !== ''),
+ isValid: prItems.length > 0,
+ hasErrors: false,
+ },
+ manager: {
+ // 임시로 담당자 필수 체크 해제
+ isValid: true,
+ hasErrors: false, // !!(formErrors.managerName || formErrors.managerEmail || formErrors.managerPhone),
+ },
}
-
- // 대표 아이템 설정 (하나만 선택 가능)
- const setRepresentativeItem = (id: string) => {
- setPrItems(prev => prev.map(item => ({
- ...item,
- isRepresentative: item.id === id
- })))
+ }, [form, specMeetingInfo.meetingDate, specMeetingInfo.location, specMeetingInfo.contactPerson, prItems])
+
+ const tabValidation = getTabValidationState()
+
+ const isCurrentTabValid = () => {
+ const validation = tabValidation[activeTab as keyof typeof tabValidation]
+ return validation?.isValid ?? true
+ }
+
+ const representativePrNumber = React.useMemo(() => {
+ const representativeItem = prItems.find((item) => item.isRepresentative)
+ return representativeItem?.prNumber || ''
+ }, [prItems])
+
+ const representativeItemName = React.useMemo(() => {
+ const representativeItem = prItems.find((item) => item.isRepresentative)
+ return representativeItem?.materialGroupInfo || ''
+ }, [prItems])
+
+ React.useEffect(() => {
+ form.setValue('hasPrDocument', hasPrDocuments)
+ form.setValue('prNumber', representativePrNumber)
+ form.setValue('itemName', representativeItemName)
+ }, [hasPrDocuments, representativePrNumber, representativeItemName, form])
+
+ const addPRItem = () => {
+ const newItem: PRItemInfo = {
+ id: `pr-${Math.random().toString(36).substr(2, 9)}`,
+ prNumber: '',
+ projectId: undefined,
+ projectInfo: '',
+ shi: '',
+ quantity: '',
+ quantityUnit: 'EA',
+ totalWeight: '',
+ weightUnit: 'KG',
+ materialDescription: '',
+ hasSpecDocument: false,
+ requestedDeliveryDate: '',
+ specFiles: [],
+ isRepresentative: prItems.length === 0,
+ annualUnitPrice: '',
+ currency: 'KRW',
+ materialGroupNumber: '',
+ materialGroupInfo: '',
+ materialNumber: '',
+ materialInfo: '',
+ priceUnit: '',
+ purchaseUnit: '1',
+ materialWeight: '',
+ wbsCode: '',
+ wbsName: '',
+ costCenterCode: '',
+ costCenterName: '',
+ glAccountCode: '',
+ glAccountName: '',
+ targetUnitPrice: '',
+ targetAmount: '',
+ targetCurrency: 'KRW',
+ budgetAmount: '',
+ budgetCurrency: 'KRW',
+ actualAmount: '',
+ actualCurrency: 'KRW',
}
+ setPrItems((prev) => [...prev, newItem])
+ }
- // 스펙 파일 추가
- const addSpecFiles = (itemId: string, files: File[]) => {
- updatePRItem(itemId, {
- specFiles: [...(prItems.find(item => item.id === itemId)?.specFiles || []), ...files]
- })
- // 파일 추가 후 선택 해제
- setSelectedItemForFile(null)
+ const removePRItem = (id: string) => {
+ if (prItems.length <= 1) {
+ toast.error('최소 하나의 품목이 필요합니다.')
+ return
}
- // 스펙 파일 제거
- const removeSpecFile = (itemId: string, fileIndex: number) => {
- const item = prItems.find(item => item.id === itemId)
- if (item) {
- const newFiles = item.specFiles.filter((_, index) => index !== fileIndex)
- updatePRItem(itemId, { specFiles: newFiles })
+ setPrItems((prev) => {
+ const filteredItems = prev.filter((item) => item.id !== id)
+ const removedItem = prev.find((item) => item.id === id)
+ if (removedItem?.isRepresentative && filteredItems.length > 0) {
+ filteredItems[0].isRepresentative = true
+ }
+ return filteredItems
+ })
+ if (selectedItemForFile === id) {
+ setSelectedItemForFile(null)
+ }
+ }
+
+ const updatePRItem = (id: string, updates: Partial<PRItemInfo>) => {
+ setPrItems((prev) =>
+ prev.map((item) => {
+ if (item.id === id) {
+ const updatedItem = { ...item, ...updates }
+ // 내정단가, 수량, 중량, 구매단위가 변경되면 내정금액 재계산
+ if (updates.targetUnitPrice || updates.quantity || updates.totalWeight || updates.purchaseUnit) {
+ updatedItem.targetAmount = calculateTargetAmount(updatedItem)
+ }
+ return updatedItem
}
+ return item
+ })
+ )
+ }
+
+ const setRepresentativeItem = (id: string) => {
+ setPrItems((prev) =>
+ prev.map((item) => ({
+ ...item,
+ isRepresentative: item.id === id,
+ }))
+ )
+ }
+
+ const handleQuantityWeightModeChange = (mode: 'quantity' | 'weight') => {
+ setQuantityWeightMode(mode)
+ }
+
+ const calculateTargetAmount = (item: PRItemInfo) => {
+ const unitPrice = parseFloat(item.targetUnitPrice) || 0
+ const purchaseUnit = parseFloat(item.purchaseUnit) || 1 // 기본값 1
+ let amount = 0
+
+ if (quantityWeightMode === 'quantity') {
+ const quantity = parseFloat(item.quantity) || 0
+ // (수량 / 구매단위) * 내정단가
+ amount = (quantity / purchaseUnit) * unitPrice
+ } else {
+ const weight = parseFloat(item.totalWeight) || 0
+ // (중량 / 구매단위) * 내정단가
+ amount = (weight / purchaseUnit) * unitPrice
}
- // ✅ 프로젝트 선택 핸들러
- const handleProjectSelect = React.useCallback((project: { id: number; code: string; name: string } | null) => {
- if (project) {
- form.setValue("projectId", project.id)
- } else {
- form.setValue("projectId", 0)
- }
- }, [form])
-
-
- // 다음 버튼 클릭 핸들러
- const handleNextClick = () => {
- // 현재 탭 validation 체크
- if (!isCurrentTabValid()) {
- // 특정 탭별 에러 메시지
- if (activeTab === "basic") {
- toast.error("기본 정보를 모두 입력해주세요 (품목명, 입찰명)")
- } else if (activeTab === "contract") {
- toast.error("계약 정보를 모두 입력해주세요")
- } else if (activeTab === "schedule") {
- if (form.watch("hasSpecificationMeeting")) {
- toast.error("사양설명회 필수 정보를 입력해주세요 (회의일시, 장소, 담당자)")
- } else {
- toast.error("제출 시작일시와 마감일시를 입력해주세요")
- }
- } else if (activeTab === "conditions") {
- toast.error("입찰 조건을 모두 입력해주세요 (지급조건, 세금조건, 운송조건, 계약납품일)")
- } else if (activeTab === "details") {
- toast.error("품목정보, 수량/단위 또는 중량/중량단위를 입력해주세요")
- }
- return
- }
+ // 소수점 버림
+ return Math.floor(amount).toString()
+ }
- goToNextTab()
+ const addSpecFiles = (itemId: string, files: File[]) => {
+ updatePRItem(itemId, {
+ specFiles: [...(prItems.find((item) => item.id === itemId)?.specFiles || []), ...files],
+ })
+ setSelectedItemForFile(null)
+ }
+
+ const removeSpecFile = (itemId: string, fileIndex: number) => {
+ const item = prItems.find((item) => item.id === itemId)
+ if (item) {
+ const newFiles = item.specFiles.filter((_, index) => index !== fileIndex)
+ updatePRItem(itemId, { specFiles: newFiles })
}
-
- // 폼 제출
- async function onSubmit(data: CreateBiddingSchema) {
- // 사양설명회 필수값 검증
- if (data.hasSpecificationMeeting) {
- const requiredFields = [
- { field: specMeetingInfo.meetingDate, name: "회의일시" },
- { field: specMeetingInfo.location, name: "회의 장소" },
- { field: specMeetingInfo.contactPerson, name: "담당자" }
- ]
-
- const missingFields = requiredFields.filter(item => !item.field.trim())
- if (missingFields.length > 0) {
- toast.error(`사양설명회 필수 정보가 누락되었습니다: ${missingFields.map(f => f.name).join(", ")}`)
- setActiveTab("schedule")
- return
- }
+ }
+
+ const handleNextClick = () => {
+ if (!isCurrentTabValid()) {
+ if (activeTab === 'basic') {
+ toast.error('기본 정보를 모두 입력해주세요.')
+ } else if (activeTab === 'schedule') {
+ if (form.watch('hasSpecificationMeeting')) {
+ toast.error('사양설명회 필수 정보를 입력해주세요.')
+ } else {
+ toast.error('제출 시작일시와 마감일시를 입력해주세요.')
}
+ } else if (activeTab === 'details') {
+ toast.error('최소 하나의 아이템이 필요하며, 모든 아이템에 자재그룹코드가 필수입니다.')
+ }
+ return
+ }
- setIsSubmitting(true)
- try {
- const userId = session?.user?.id?.toString() || "1"
-
- // 추가 데이터 준비
- const extendedData = {
- ...data,
- hasPrDocument: hasPrDocuments, // 자동 계산된 값 사용
- prNumber: representativePrNumber, // 대표 아이템의 PR 번호 사용
- specificationMeeting: data.hasSpecificationMeeting ? {
- ...specMeetingInfo,
- meetingFiles: specMeetingInfo.meetingFiles
- } : null,
- prItems: prItems.length > 0 ? prItems : [],
- biddingConditions: biddingConditions,
- }
-
- const result = await createBidding(extendedData, userId)
-
- if (result.success) {
- toast.success((result as { success: true; message: string }).message || "입찰이 성공적으로 생성되었습니다.")
- setOpen(false)
- router.refresh()
-
- // 생성된 입찰 상세페이지로 이동할지 묻기
- if (result.success && 'data' in result && result.data?.id) {
- setCreatedBiddingId(result.data.id)
- setShowSuccessDialog(true)
- }
- } else {
- const errorMessage = result.success === false && 'error' in result ? result.error : "입찰 생성에 실패했습니다."
- toast.error(errorMessage)
- }
- } catch (error) {
- console.error("Error creating bidding:", error)
- toast.error("입찰 생성 중 오류가 발생했습니다.")
- } finally {
- setIsSubmitting(false)
- }
+ goToNextTab()
+ }
+
+ async function onSubmit(data: CreateBiddingSchema) {
+ if (data.hasSpecificationMeeting) {
+ const requiredFields = [
+ { field: specMeetingInfo.meetingDate, name: '회의일시' },
+ { field: specMeetingInfo.location, name: '회의 장소' },
+ { field: specMeetingInfo.contactPerson, name: '담당자' },
+ ]
+
+ const missingFields = requiredFields.filter((item) => !item.field.trim())
+ if (missingFields.length > 0) {
+ toast.error(`사양설명회 필수 정보가 누락되었습니다: ${missingFields.map((f) => f.name).join(', ')}`)
+ setActiveTab('schedule')
+ return
+ }
}
- // 폼 및 상태 초기화 함수
- const resetAllStates = React.useCallback(() => {
- // 폼 초기화
- form.reset({
- revision: 0,
- projectId: 0,
- projectName: "",
- itemName: "",
- title: "",
- description: "",
- content: "",
- contractType: "general",
- biddingType: "equipment",
- biddingTypeCustom: "",
- awardCount: "single",
- contractStartDate: "",
- contractEndDate: "",
- submissionStartDate: "",
- submissionEndDate: "",
- hasSpecificationMeeting: false,
- prNumber: "",
- currency: "KRW",
- status: "bidding_generated",
- isPublic: false,
- managerName: "",
- managerEmail: "",
- managerPhone: "",
- remarks: "",
- })
-
- // 추가 상태들 초기화
- setSpecMeetingInfo({
- meetingDate: "",
- meetingTime: "",
- location: "",
- address: "",
- contactPerson: "",
- contactPhone: "",
- contactEmail: "",
- agenda: "",
- materials: "",
- notes: "",
- isRequired: false,
- meetingFiles: [],
- })
- setPrItems([
- {
- id: `pr-default`,
- prNumber: "",
- itemCode: "",
- itemInfo: "",
- quantity: "",
- quantityUnit: "EA",
- totalWeight: "",
- weightUnit: "KG",
- materialDescription: "",
- hasSpecDocument: false,
- requestedDeliveryDate: "",
- specFiles: [],
- isRepresentative: true, // 첫 번째 아이템은 대표 아이템
+ setIsSubmitting(true)
+ try {
+ const userId = session?.user?.id?.toString() || '1'
+
+ const extendedData = {
+ ...data,
+ hasPrDocument: hasPrDocuments,
+ prNumber: representativePrNumber,
+ specificationMeeting: data.hasSpecificationMeeting
+ ? {
+ ...specMeetingInfo,
+ meetingFiles: specMeetingInfo.meetingFiles,
}
- ])
- setSelectedItemForFile(null)
- setBiddingConditions({
- paymentTerms: "",
- taxConditions: "",
- incoterms: "",
- contractDeliveryDate: "",
- shippingPort: "",
- destinationPort: "",
- isPriceAdjustmentApplicable: false,
- sparePartOptions: "",
- })
- setActiveTab("basic")
- setShowSuccessDialog(false) // 추가
- setCreatedBiddingId(null) // 추가
- }, [form])
-
- // 다이얼로그 핸들러
- function handleDialogOpenChange(nextOpen: boolean) {
- if (!nextOpen) {
- // 닫으려 할 때 확인 창을 먼저 띄움
- setShowCloseConfirmDialog(true)
- } else {
- // 열 때는 바로 적용
- setOpen(nextOpen)
+ : null,
+ prItems: prItems.length > 0 ? prItems : [],
+ biddingConditions: biddingConditions,
+ }
+
+ const result = await createBidding(extendedData, userId)
+
+ if (result.success) {
+ toast.success(
+ (result as { success: true; message: string }).message || '입찰이 성공적으로 생성되었습니다.'
+ )
+ setOpen(false)
+ router.refresh()
+ if (result.success && 'data' in result && result.data?.id) {
+ setCreatedBiddingId(result.data.id)
+ setShowSuccessDialog(true)
}
+ } else {
+ const errorMessage =
+ result.success === false && 'error' in result ? result.error : '입찰 생성에 실패했습니다.'
+ toast.error(errorMessage)
+ }
+ } catch (error) {
+ console.error('Error creating bidding:', error)
+ toast.error('입찰 생성 중 오류가 발생했습니다.')
+ } finally {
+ setIsSubmitting(false)
}
+ }
+
+ const resetAllStates = React.useCallback(() => {
+ form.reset({
+ revision: 0,
+ projectId: 0,
+ projectName: '',
+ itemName: '',
+ title: '',
+ description: '',
+ content: '',
+ contractType: 'general',
+ biddingType: 'equipment',
+ biddingTypeCustom: '',
+ awardCount: 'single',
+ contractStartDate: '',
+ contractEndDate: '',
+ submissionStartDate: '',
+ submissionEndDate: '',
+ hasSpecificationMeeting: false,
+ prNumber: '',
+ currency: 'KRW',
+ status: 'bidding_generated',
+ isPublic: false,
+ purchasingOrganization: '',
+ managerName: '',
+ managerEmail: '',
+ managerPhone: '',
+ remarks: '',
+ })
- // 닫기 확인 핸들러
- const handleCloseConfirm = (confirmed: boolean) => {
- setShowCloseConfirmDialog(false)
- if (confirmed) {
- // 사용자가 "예"를 선택한 경우 실제로 닫기
- resetAllStates()
- setOpen(false)
- }
- // "아니오"를 선택한 경우는 아무것도 하지 않음 (다이얼로그 유지)
+ setSpecMeetingInfo({
+ meetingDate: '',
+ meetingTime: '',
+ location: '',
+ address: '',
+ contactPerson: '',
+ contactPhone: '',
+ contactEmail: '',
+ agenda: '',
+ materials: '',
+ notes: '',
+ isRequired: false,
+ meetingFiles: [],
+ })
+ setPrItems([
+ {
+ id: `pr-default`,
+ prNumber: '',
+ projectId: undefined,
+ projectInfo: '',
+ shi: '',
+ quantity: '',
+ quantityUnit: 'EA',
+ totalWeight: '',
+ weightUnit: 'KG',
+ materialDescription: '',
+ hasSpecDocument: false,
+ requestedDeliveryDate: '',
+ specFiles: [],
+ isRepresentative: true,
+ annualUnitPrice: '',
+ currency: 'KRW',
+ materialGroupNumber: '',
+ materialGroupInfo: '',
+ materialNumber: '',
+ materialInfo: '',
+ priceUnit: '',
+ purchaseUnit: '',
+ materialWeight: '',
+ wbsCode: '',
+ wbsName: '',
+ costCenterCode: '',
+ costCenterName: '',
+ glAccountCode: '',
+ glAccountName: '',
+ targetUnitPrice: '',
+ targetAmount: '',
+ targetCurrency: 'KRW',
+ budgetAmount: '',
+ budgetCurrency: 'KRW',
+ actualAmount: '',
+ actualCurrency: 'KRW',
+ },
+ ])
+ setSelectedItemForFile(null)
+ setCostCenterDialogOpen(false)
+ setGlAccountDialogOpen(false)
+ setWbsCodeDialogOpen(false)
+ setMaterialGroupDialogOpen(false)
+ setMaterialDialogOpen(false)
+ setBiddingConditions({
+ paymentTerms: '',
+ taxConditions: '',
+ incoterms: '',
+ incotermsOption: '',
+ contractDeliveryDate: '',
+ shippingPort: '',
+ destinationPort: '',
+ isPriceAdjustmentApplicable: false,
+ sparePartOptions: '',
+ })
+ setActiveTab('basic')
+ setShowSuccessDialog(false)
+ setCreatedBiddingId(null)
+ }, [form])
+
+ function handleDialogOpenChange(nextOpen: boolean) {
+ if (!nextOpen) {
+ setShowCloseConfirmDialog(true)
+ } else {
+ setOpen(nextOpen)
}
+ }
- // 입찰 생성 버튼 클릭 핸들러 추가
- const handleCreateBidding = () => {
- // 마지막 탭 validation 체크
- if (!isCurrentTabValid()) {
- toast.error("필수 정보를 모두 입력해주세요.")
- return
- }
-
- // 수동으로 폼 제출
- form.handleSubmit(onSubmit)()
+ const handleCloseConfirm = (confirmed: boolean) => {
+ setShowCloseConfirmDialog(false)
+ if (confirmed) {
+ resetAllStates()
+ setOpen(false)
}
+ }
- // 성공 다이얼로그 핸들러들
- const handleNavigateToDetail = () => {
- if (createdBiddingId) {
- router.push(`/evcp/bid/${createdBiddingId}`)
- }
- setShowSuccessDialog(false)
- setCreatedBiddingId(null)
+ const handleCreateBidding = () => {
+ if (!isCurrentTabValid()) {
+ toast.error('필수 정보를 모두 입력해주세요.')
+ return
}
- const handleStayOnPage = () => {
- setShowSuccessDialog(false)
- setCreatedBiddingId(null)
+ form.handleSubmit(onSubmit)()
+ }
+
+ const handleNavigateToDetail = () => {
+ if (createdBiddingId) {
+ router.push(`/evcp/bid/${createdBiddingId}`)
}
+ setShowSuccessDialog(false)
+ setCreatedBiddingId(null)
+ }
+ const handleStayOnPage = () => {
+ setShowSuccessDialog(false)
+ setCreatedBiddingId(null)
+ }
+ // PR 아이템 테이블 렌더링
+ const renderPrItemsTable = () => {
return (
- <>
- <Dialog open={open} onOpenChange={handleDialogOpenChange}>
- <DialogTrigger asChild>
- <Button variant="default" size="sm">
- <Plus className="mr-2 h-4 w-4" />
- 신규 입찰
+ <div className="border rounded-lg overflow-hidden">
+ <div className="overflow-x-auto">
+ <table className="w-full border-collapse">
+ <thead className="bg-muted/50">
+ <tr>
+ <th className="sticky left-0 z-10 bg-muted/50 border-r px-2 py-3 text-left text-xs font-medium min-w-[50px]">
+ <span className="sr-only">대표</span>
+ </th>
+ <th className="sticky left-[50px] z-10 bg-muted/50 border-r px-3 py-3 text-left text-xs font-medium min-w-[40px]">
+ #
+ </th>
+ <th className="border-r px-3 py-3 text-left text-xs font-medium min-w-[120px]">프로젝트코드</th>
+ <th className="border-r px-3 py-3 text-left text-xs font-medium min-w-[300px]">프로젝트명</th>
+ <th className="border-r px-3 py-3 text-left text-xs font-medium min-w-[150px]">자재그룹코드 <span className="text-red-500">*</span></th>
+ <th className="border-r px-3 py-3 text-left text-xs font-medium min-w-[300px]">자재그룹명 <span className="text-red-500">*</span></th>
+ <th className="border-r px-3 py-3 text-left text-xs font-medium min-w-[150px]">자재코드</th>
+ <th className="border-r px-3 py-3 text-left text-xs font-medium min-w-[300px]">자재명</th>
+ <th className="border-r px-3 py-3 text-left text-xs font-medium min-w-[120px]">수량</th>
+ <th className="border-r px-3 py-3 text-left text-xs font-medium min-w-[80px]">단위</th>
+ <th className="border-r px-3 py-3 text-left text-xs font-medium min-w-[80px]">구매단위</th>
+ <th className="border-r px-3 py-3 text-left text-xs font-medium min-w-[120px]">내정단가</th>
+ <th className="border-r px-3 py-3 text-left text-xs font-medium min-w-[120px]">내정금액</th>
+ <th className="border-r px-3 py-3 text-left text-xs font-medium min-w-[80px]">내정통화</th>
+ <th className="border-r px-3 py-3 text-left text-xs font-medium min-w-[120px]">예산금액</th>
+ <th className="border-r px-3 py-3 text-left text-xs font-medium min-w-[80px]">예산통화</th>
+ <th className="border-r px-3 py-3 text-left text-xs font-medium min-w-[120px]">실적금액</th>
+ <th className="border-r px-3 py-3 text-left text-xs font-medium min-w-[80px]">실적통화</th>
+ <th className="border-r px-3 py-3 text-left text-xs font-medium min-w-[300px]">WBS코드</th>
+ <th className="border-r px-3 py-3 text-left text-xs font-medium min-w-[150px]">WBS명</th>
+ <th className="border-r px-3 py-3 text-left text-xs font-medium min-w-[120px]">코스트센터코드</th>
+ <th className="border-r px-3 py-3 text-left text-xs font-medium min-w-[150px]">코스트센터명</th>
+ <th className="border-r px-3 py-3 text-left text-xs font-medium min-w-[120px]">GL계정코드</th>
+ <th className="border-r px-3 py-3 text-left text-xs font-medium min-w-[150px]">GL계정명</th>
+ <th className="border-r px-3 py-3 text-left text-xs font-medium min-w-[120px]">납품요청일</th>
+ <th className="sticky right-0 z-10 bg-muted/50 border-l px-3 py-3 text-center text-xs font-medium min-w-[100px]">
+ 액션
+ </th>
+ </tr>
+ </thead>
+ <tbody>
+ {prItems.map((item, index) => (
+ <tr key={item.id} className="border-t hover:bg-muted/30">
+ <td className="sticky left-0 z-10 bg-background border-r px-2 py-2 text-center">
+ <Checkbox
+ checked={item.isRepresentative}
+ onCheckedChange={() => setRepresentativeItem(item.id)}
+ disabled={prItems.length <= 1 && item.isRepresentative}
+ title="대표 아이템"
+ />
+ </td>
+ <td className="sticky left-[50px] z-10 bg-background border-r px-3 py-2 text-xs text-muted-foreground">
+ {index + 1}
+ </td>
+ <td className="border-r px-3 py-2">
+ <ProjectSelector
+ selectedProjectId={item.projectId || null}
+ onProjectSelect={(project) => {
+ updatePRItem(item.id, {
+ projectId: project.id,
+ projectInfo: project.projectName
+ })
+ }}
+ placeholder="프로젝트 선택"
+ />
+ </td>
+ <td className="border-r px-3 py-2">
+ <Input
+ placeholder="프로젝트명"
+ value={item.projectInfo || ''}
+ readOnly
+ className="h-8 text-xs bg-muted/50"
+ />
+ </td>
+ <td className="border-r px-3 py-2">
+ <MaterialGroupSelectorDialogSingle
+ triggerLabel={item.materialGroupNumber || "자재그룹 선택"}
+ triggerVariant="outline"
+ selectedMaterial={item.materialGroupNumber ? {
+ materialGroupCode: item.materialGroupNumber,
+ materialGroupDescription: item.materialGroupInfo,
+ displayText: `${item.materialGroupNumber} - ${item.materialGroupInfo}`
+ } : null}
+ onMaterialSelect={(material) => {
+ if (material) {
+ updatePRItem(item.id, {
+ materialGroupNumber: material.materialGroupCode,
+ materialGroupInfo: material.materialGroupDescription
+ })
+ } else {
+ updatePRItem(item.id, {
+ materialGroupNumber: '',
+ materialGroupInfo: ''
+ })
+ }
+ }}
+ title="자재그룹 선택"
+ description="자재그룹을 검색하고 선택해주세요."
+ />
+ </td>
+ <td className="border-r px-3 py-2">
+ <Input
+ placeholder="자재그룹명"
+ value={item.materialGroupInfo}
+ readOnly
+ className="h-8 text-xs bg-muted/50"
+ />
+ </td>
+ <td className="border-r px-3 py-2">
+ <MaterialSelectorDialogSingle
+ triggerLabel={item.materialNumber || "자재 선택"}
+ triggerVariant="outline"
+ selectedMaterial={item.materialNumber ? {
+ materialCode: item.materialNumber,
+ materialName: item.materialInfo,
+ displayText: `${item.materialNumber} - ${item.materialInfo}`
+ } : null}
+ onMaterialSelect={(material) => {
+ if (material) {
+ updatePRItem(item.id, {
+ materialNumber: material.materialCode,
+ materialInfo: material.materialName
+ })
+ } else {
+ updatePRItem(item.id, {
+ materialNumber: '',
+ materialInfo: ''
+ })
+ }
+ }}
+ title="자재 선택"
+ description="자재를 검색하고 선택해주세요."
+ />
+ </td>
+ <td className="border-r px-3 py-2">
+ <Input
+ placeholder="자재명"
+ value={item.materialInfo}
+ readOnly
+ className="h-8 text-xs bg-muted/50"
+ />
+ </td>
+ <td className="border-r px-3 py-2">
+ {quantityWeightMode === 'quantity' ? (
+ <Input
+ type="number"
+ min="0"
+ placeholder="수량"
+ value={item.quantity}
+ onChange={(e) => updatePRItem(item.id, { quantity: e.target.value })}
+ className="h-8 text-xs"
+ />
+ ) : (
+ <Input
+ type="number"
+ min="0"
+ placeholder="중량"
+ value={item.totalWeight}
+ onChange={(e) => updatePRItem(item.id, { totalWeight: e.target.value })}
+ className="h-8 text-xs"
+ />
+ )}
+ </td>
+ <td className="border-r px-3 py-2">
+ {quantityWeightMode === 'quantity' ? (
+ <Select
+ value={item.quantityUnit}
+ onValueChange={(value) => updatePRItem(item.id, { quantityUnit: value })}
+ >
+ <SelectTrigger className="h-8 text-xs">
+ <SelectValue />
+ </SelectTrigger>
+ <SelectContent>
+ <SelectItem value="EA">EA</SelectItem>
+ <SelectItem value="SET">SET</SelectItem>
+ <SelectItem value="LOT">LOT</SelectItem>
+ <SelectItem value="M">M</SelectItem>
+ <SelectItem value="M2">M²</SelectItem>
+ <SelectItem value="M3">M³</SelectItem>
+ </SelectContent>
+ </Select>
+ ) : (
+ <Select
+ value={item.weightUnit}
+ onValueChange={(value) => updatePRItem(item.id, { weightUnit: value })}
+ >
+ <SelectTrigger className="h-8 text-xs">
+ <SelectValue />
+ </SelectTrigger>
+ <SelectContent>
+ <SelectItem value="KG">KG</SelectItem>
+ <SelectItem value="TON">TON</SelectItem>
+ <SelectItem value="G">G</SelectItem>
+ <SelectItem value="LB">LB</SelectItem>
+ </SelectContent>
+ </Select>
+ )}
+ </td>
+ <td className="border-r px-3 py-2">
+ <Input
+ type="number"
+ min="1"
+ step="1"
+ placeholder="구매단위"
+ value={item.purchaseUnit || ''}
+ onChange={(e) => updatePRItem(item.id, { purchaseUnit: e.target.value })}
+ className="h-8 text-xs"
+ />
+ </td>
+ <td className="border-r px-3 py-2">
+ <Input
+ type="number"
+ min="0"
+ step="1"
+ placeholder="내정단가"
+ value={item.targetUnitPrice || ''}
+ onChange={(e) => updatePRItem(item.id, { targetUnitPrice: e.target.value })}
+ className="h-8 text-xs"
+ />
+ </td>
+ <td className="border-r px-3 py-2">
+ <Input
+ type="number"
+ min="0"
+ step="1"
+ placeholder="내정금액"
+ readOnly
+ value={item.targetAmount || ''}
+ className="h-8 text-xs bg-muted/50"
+ />
+ </td>
+ <td className="border-r px-3 py-2">
+ <Select
+ value={item.targetCurrency}
+ onValueChange={(value) => updatePRItem(item.id, { targetCurrency: value })}
+ >
+ <SelectTrigger className="h-8 text-xs">
+ <SelectValue />
+ </SelectTrigger>
+ <SelectContent>
+ <SelectItem value="KRW">KRW</SelectItem>
+ <SelectItem value="USD">USD</SelectItem>
+ <SelectItem value="EUR">EUR</SelectItem>
+ <SelectItem value="JPY">JPY</SelectItem>
+ </SelectContent>
+ </Select>
+ </td>
+ <td className="border-r px-3 py-2">
+ <Input
+ type="number"
+ min="0"
+ step="1"
+ placeholder="예산금액"
+ value={item.budgetAmount || ''}
+ onChange={(e) => updatePRItem(item.id, { budgetAmount: e.target.value })}
+ className="h-8 text-xs"
+ />
+ </td>
+ <td className="border-r px-3 py-2">
+ <Select
+ value={item.budgetCurrency}
+ onValueChange={(value) => updatePRItem(item.id, { budgetCurrency: value })}
+ >
+ <SelectTrigger className="h-8 text-xs">
+ <SelectValue />
+ </SelectTrigger>
+ <SelectContent>
+ <SelectItem value="KRW">KRW</SelectItem>
+ <SelectItem value="USD">USD</SelectItem>
+ <SelectItem value="EUR">EUR</SelectItem>
+ <SelectItem value="JPY">JPY</SelectItem>
+ </SelectContent>
+ </Select>
+ </td>
+ <td className="border-r px-3 py-2">
+ <Input
+ type="number"
+ min="0"
+ step="1"
+ placeholder="실적금액"
+ value={item.actualAmount || ''}
+ onChange={(e) => updatePRItem(item.id, { actualAmount: e.target.value })}
+ className="h-8 text-xs"
+ />
+ </td>
+ <td className="border-r px-3 py-2">
+ <Select
+ value={item.actualCurrency}
+ onValueChange={(value) => updatePRItem(item.id, { actualCurrency: value })}
+ >
+ <SelectTrigger className="h-8 text-xs">
+ <SelectValue />
+ </SelectTrigger>
+ <SelectContent>
+ <SelectItem value="KRW">KRW</SelectItem>
+ <SelectItem value="USD">USD</SelectItem>
+ <SelectItem value="EUR">EUR</SelectItem>
+ <SelectItem value="JPY">JPY</SelectItem>
+ </SelectContent>
+ </Select>
+ </td>
+ <td className="border-r px-3 py-2">
+ <Button
+ variant="outline"
+ onClick={() => setWbsCodeDialogOpen(true)}
+ className="w-full justify-start h-8 text-xs"
+ >
+ {item.wbsCode ? (
+ <span className="truncate">
+ {`${item.wbsCode}${item.wbsName ? ` - ${item.wbsName}` : ''}`}
+ </span>
+ ) : (
+ <span className="text-muted-foreground">WBS 코드 선택</span>
+ )}
+ </Button>
+ <WbsCodeSingleSelector
+ open={wbsCodeDialogOpen}
+ onOpenChange={setWbsCodeDialogOpen}
+ selectedCode={item.wbsCode ? {
+ PROJ_NO: '',
+ WBS_ELMT: item.wbsCode,
+ WBS_ELMT_NM: item.wbsName || '',
+ WBS_LVL: ''
+ } : undefined}
+ onCodeSelect={(wbsCode) => {
+ updatePRItem(item.id, {
+ wbsCode: wbsCode.WBS_ELMT,
+ wbsName: wbsCode.WBS_ELMT_NM
+ })
+ setWbsCodeDialogOpen(false)
+ }}
+ title="WBS 코드 선택"
+ description="WBS 코드를 선택하세요"
+ showConfirmButtons={false}
+ />
+ </td>
+ <td className="border-r px-3 py-2">
+ <Input
+ placeholder="WBS명"
+ value={item.wbsName || ''}
+ readOnly
+ className="h-8 text-xs bg-muted/50"
+ />
+ </td>
+ <td className="border-r px-3 py-2">
+ <Button
+ variant="outline"
+ onClick={() => setCostCenterDialogOpen(true)}
+ className="w-full justify-start h-8 text-xs"
+ >
+ {item.costCenterCode ? (
+ <span className="truncate">
+ {`${item.costCenterCode}${item.costCenterName ? ` - ${item.costCenterName}` : ''}`}
+ </span>
+ ) : (
+ <span className="text-muted-foreground">코스트센터 선택</span>
+ )}
</Button>
- </DialogTrigger>
- <DialogContent className="max-w-7xl h-[90vh] p-0 flex flex-col">
- {/* 고정 헤더 */}
- <div className="flex-shrink-0 p-6 border-b">
- <DialogHeader>
- <DialogTitle>신규 입찰 생성</DialogTitle>
- <DialogDescription>
- 새로운 입찰을 생성합니다. 단계별로 정보를 입력해주세요.
- </DialogDescription>
- </DialogHeader>
+ <CostCenterSingleSelector
+ open={costCenterDialogOpen}
+ onOpenChange={setCostCenterDialogOpen}
+ selectedCode={item.costCenterCode ? {
+ KOSTL: item.costCenterCode,
+ KTEXT: '',
+ LTEXT: item.costCenterName || '',
+ DATAB: '',
+ DATBI: ''
+ } : undefined}
+ onCodeSelect={(costCenter) => {
+ updatePRItem(item.id, {
+ costCenterCode: costCenter.KOSTL,
+ costCenterName: costCenter.LTEXT
+ })
+ setCostCenterDialogOpen(false)
+ }}
+ title="코스트센터 선택"
+ description="코스트센터를 선택하세요"
+ showConfirmButtons={false}
+ />
+ </td>
+ <td className="border-r px-3 py-2">
+ <Input
+ placeholder="코스트센터명"
+ value={item.costCenterName || ''}
+ readOnly
+ className="h-8 text-xs bg-muted/50"
+ />
+ </td>
+ <td className="border-r px-3 py-2">
+ <Button
+ variant="outline"
+ onClick={() => setGlAccountDialogOpen(true)}
+ className="w-full justify-start h-8 text-xs"
+ >
+ {item.glAccountCode ? (
+ <span className="truncate">
+ {`${item.glAccountCode}${item.glAccountName ? ` - ${item.glAccountName}` : ''}`}
+ </span>
+ ) : (
+ <span className="text-muted-foreground">GL계정 선택</span>
+ )}
+ </Button>
+ <GlAccountSingleSelector
+ open={glAccountDialogOpen}
+ onOpenChange={setGlAccountDialogOpen}
+ selectedCode={item.glAccountCode ? {
+ SAKNR: item.glAccountCode,
+ FIPEX: '',
+ TEXT1: item.glAccountName || ''
+ } : undefined}
+ onCodeSelect={(glAccount) => {
+ updatePRItem(item.id, {
+ glAccountCode: glAccount.SAKNR,
+ glAccountName: glAccount.TEXT1
+ })
+ setGlAccountDialogOpen(false)
+ }}
+ title="GL 계정 선택"
+ description="GL 계정을 선택하세요"
+ showConfirmButtons={false}
+ />
+ </td>
+ <td className="border-r px-3 py-2">
+ <Input
+ placeholder="GL계정명"
+ value={item.glAccountName || ''}
+ readOnly
+ className="h-8 text-xs bg-muted/50"
+ />
+ </td>
+ <td className="border-r px-3 py-2">
+ <Input
+ type="date"
+ value={item.requestedDeliveryDate}
+ onChange={(e) => updatePRItem(item.id, { requestedDeliveryDate: e.target.value })}
+ className="h-8 text-xs"
+ />
+ </td>
+ <td className="sticky right-0 z-10 bg-background border-l px-3 py-2">
+ <div className="flex items-center justify-center gap-1">
+ <Button
+ type="button"
+ variant="ghost"
+ size="sm"
+ onClick={() => {
+ const fileInput = document.createElement('input')
+ fileInput.type = 'file'
+ fileInput.multiple = true
+ fileInput.accept = '.pdf,.doc,.docx,.xls,.xlsx,.png,.jpg,.jpeg'
+ fileInput.onchange = (e) => {
+ const files = Array.from((e.target as HTMLInputElement).files || [])
+ if (files.length > 0) {
+ addSpecFiles(item.id, files)
+ }
+ }
+ fileInput.click()
+ }}
+ className="h-7 w-7 p-0"
+ title="파일 첨부"
+ >
+ <Paperclip className="h-3.5 w-3.5" />
+ {item.specFiles.length > 0 && (
+ <span className="absolute -top-1 -right-1 bg-primary text-primary-foreground rounded-full w-4 h-4 text-[10px] flex items-center justify-center">
+ {item.specFiles.length}
+ </span>
+ )}
+ </Button>
+ <Button
+ type="button"
+ variant="ghost"
+ size="sm"
+ onClick={() => removePRItem(item.id)}
+ disabled={prItems.length <= 1}
+ className="h-7 w-7 p-0"
+ title="품목 삭제"
+ >
+ <Trash2 className="h-3.5 w-3.5" />
+ </Button>
</div>
-
- <Form {...form}>
- <form
- onSubmit={form.handleSubmit(onSubmit)}
- className="flex flex-col flex-1 min-h-0"
- id="create-bidding-form"
+ </td>
+ </tr>
+ ))}
+ </tbody>
+ </table>
+ </div>
+
+ {/* 첨부된 파일 목록 표시 */}
+ {prItems.some(item => item.specFiles.length > 0) && (
+ <div className="border-t p-4 bg-muted/20">
+ <Label className="text-sm font-medium mb-2 block">첨부된 스펙 파일</Label>
+ <div className="space-y-3">
+ {prItems.map((item, index) => (
+ item.specFiles.length > 0 && (
+ <div key={item.id} className="space-y-1">
+ <div className="text-xs font-medium text-muted-foreground">
+ {item.materialGroupInfo || item.materialGroupNumber || `ITEM-${index + 1}`}
+ </div>
+ <div className="flex flex-wrap gap-2">
+ {item.specFiles.map((file, fileIndex) => (
+ <div
+ key={fileIndex}
+ className="inline-flex items-center gap-1 px-2 py-1 bg-background border rounded text-xs"
+ >
+ <Paperclip className="h-3 w-3" />
+ <span className="max-w-[200px] truncate">{file.name}</span>
+ <span className="text-muted-foreground">
+ ({(file.size / 1024 / 1024).toFixed(2)} MB)
+ </span>
+ <Button
+ type="button"
+ variant="ghost"
+ size="sm"
+ onClick={() => removeSpecFile(item.id, fileIndex)}
+ className="h-4 w-4 p-0 ml-1 hover:bg-destructive/20"
+ >
+ <X className="h-3 w-3" />
+ </Button>
+ </div>
+ ))}
+ </div>
+ </div>
+ )
+ ))}
+ </div>
+ </div>
+ )}
+ </div>
+ )
+ }
+
+
+ return (
+ <>
+ <Dialog open={open} onOpenChange={handleDialogOpenChange}>
+ <DialogTrigger asChild>
+ <Button variant="default" size="sm">
+ <Plus className="mr-2 h-4 w-4" />
+ 신규 입찰
+ </Button>
+ </DialogTrigger>
+ <DialogContent className="h-[90vh] p-0 flex flex-col" style={{ maxWidth: '1400px' }}>
+ {/* 고정 헤더 */}
+ <div className="flex-shrink-0 p-6 border-b">
+ <DialogHeader>
+ <DialogTitle>신규 입찰 생성</DialogTitle>
+ <DialogDescription>
+ 새로운 입찰을 생성합니다. 단계별로 정보를 입력해주세요.
+ </DialogDescription>
+ </DialogHeader>
+ </div>
+
+ <Form {...form}>
+ <form onSubmit={form.handleSubmit(onSubmit)} className="flex flex-col flex-1 min-h-0" id="create-bidding-form">
+ {/* 탭 영역 */}
+ <div className="flex-1 overflow-hidden">
+ <Tabs value={activeTab} onValueChange={(value) => setActiveTab(value as TabType)} className="h-full flex flex-col">
+ {/* 탭 리스트 */}
+ <div className="px-6">
+ <div className="flex space-x-1 bg-muted p-1 rounded-lg overflow-x-auto">
+ {TAB_ORDER.map((tab) => (
+ <button
+ key={tab}
+ type="button"
+ onClick={() => setActiveTab(tab)}
+ className={cn(
+ 'relative px-3 py-2 text-sm font-medium rounded-md transition-all whitespace-nowrap flex-shrink-0',
+ activeTab === tab ? 'bg-background text-foreground shadow-sm' : 'text-muted-foreground hover:text-foreground'
+ )}
>
- {/* 탭 영역 */}
- <div className="flex-1 overflow-hidden">
- <Tabs value={activeTab} onValueChange={(value) => setActiveTab(value as TabType)} className="h-full flex flex-col">
- <div className="px-6">
- <div className="flex space-x-1 bg-muted p-1 rounded-lg overflow-x-auto">
- <button
- type="button"
- onClick={() => setActiveTab("basic")}
- className={`relative px-3 py-2 text-sm font-medium rounded-md transition-all whitespace-nowrap flex-shrink-0 ${
- activeTab === "basic"
- ? "bg-background text-foreground shadow-sm"
- : "text-muted-foreground hover:text-foreground"
- }`}
- >
- 기본정보
- {!tabValidation.basic.isValid && (
- <span className="absolute -top-1 -right-1 h-2 w-2 bg-red-500 rounded-full"></span>
- )}
- </button>
- <button
- type="button"
- onClick={() => setActiveTab("schedule")}
- className={`relative px-3 py-2 text-sm font-medium rounded-md transition-all whitespace-nowrap flex-shrink-0 ${
- activeTab === "schedule"
- ? "bg-background text-foreground shadow-sm"
- : "text-muted-foreground hover:text-foreground"
- }`}
- >
- 입찰계획
- {!tabValidation.schedule.isValid && (
- <span className="absolute -top-1 -right-1 h-2 w-2 bg-red-500 rounded-full"></span>
- )}
- </button>
- <button
- type="button"
- onClick={() => setActiveTab("details")}
- className={`relative px-3 py-2 text-sm font-medium rounded-md transition-all whitespace-nowrap flex-shrink-0 ${
- activeTab === "details"
- ? "bg-background text-foreground shadow-sm"
- : "text-muted-foreground hover:text-foreground"
- }`}
- >
- 세부내역
- {!tabValidation.details.isValid && (
- <span className="absolute -top-1 -right-1 h-2 w-2 bg-red-500 rounded-full"></span>
- )}
- </button>
- <button
- type="button"
- onClick={() => setActiveTab("manager")}
- className={`relative px-3 py-2 text-sm font-medium rounded-md transition-all whitespace-nowrap flex-shrink-0 ${
- activeTab === "manager"
- ? "bg-background text-foreground shadow-sm"
- : "text-muted-foreground hover:text-foreground"
- }`}
- >
- 담당자
- </button>
- </div>
- </div>
-
- <div className="flex-1 overflow-y-auto p-6">
- {/* 기본 정보 탭 */}
- <TabsContent value="basic" className="mt-0 space-y-6">
- <Card>
- <CardHeader>
- <CardTitle>기본 정보 및 계약 정보</CardTitle>
- </CardHeader>
- <CardContent className="space-y-6">
- {/* 프로젝트 선택 */}
- <FormField
- control={form.control}
- name="projectId"
- render={({ field }) => (
- <FormItem>
- <FormLabel>
- 프로젝트
- </FormLabel>
- <FormControl>
- <ProjectSelector
- selectedProjectId={field.value}
- onProjectSelect={handleProjectSelect}
- placeholder="프로젝트 선택..."
- />
- </FormControl>
- <FormMessage />
- </FormItem>
- )}
- />
-
- {/* <div className="grid grid-cols-2 gap-6"> */}
- {/* 품목명 */}
- {/* <FormField
- control={form.control}
- name="itemName"
- render={({ field }) => (
- <FormItem>
- <FormLabel>
- 품목명 <span className="text-red-500">*</span>
- </FormLabel>
- <FormControl>
- <Input
- placeholder="품목명"
- {...field}
- />
- </FormControl>
- <FormMessage />
- </FormItem>
- )}
- /> */}
-
- {/* 리비전 */}
- {/* <FormField
- control={form.control}
- name="revision"
- render={({ field }) => (
- <FormItem>
- <FormLabel>리비전</FormLabel>
- <FormControl>
- <Input
- type="number"
- min="0"
- {...field}
- onChange={(e) => field.onChange(parseInt(e.target.value) || 0)}
- />
- </FormControl>
- <FormMessage />
- </FormItem>
- )}
- /> */}
- {/* </div> */}
-
- {/* 입찰명 */}
- <FormField
- control={form.control}
- name="title"
- render={({ field }) => (
- <FormItem>
- <FormLabel>
- 입찰명 <span className="text-red-500">*</span>
- </FormLabel>
- <FormControl>
- <Input
- placeholder="입찰명을 입력하세요"
- {...field}
- />
- </FormControl>
- <FormMessage />
- </FormItem>
- )}
- />
-
- {/* 설명 */}
- <FormField
- control={form.control}
- name="description"
- render={({ field }) => (
- <FormItem>
- <FormLabel>설명</FormLabel>
- <FormControl>
- <Textarea
- placeholder="입찰에 대한 설명을 입력하세요"
- rows={4}
- {...field}
- />
- </FormControl>
- <FormMessage />
- </FormItem>
- )}
- />
-
- {/* 계약 정보 섹션 */}
- <div className="grid grid-cols-2 gap-6">
- {/* 계약구분 */}
- <FormField
- control={form.control}
- name="contractType"
- render={({ field }) => (
- <FormItem>
- <FormLabel>
- 계약구분 <span className="text-red-500">*</span>
- </FormLabel>
- <Select onValueChange={field.onChange} defaultValue={field.value}>
- <FormControl>
- <SelectTrigger>
- <SelectValue placeholder="계약구분 선택" />
- </SelectTrigger>
- </FormControl>
- <SelectContent>
- {Object.entries(contractTypeLabels).map(([value, label]) => (
- <SelectItem key={value} value={value}>
- {label}
- </SelectItem>
- ))}
- </SelectContent>
- </Select>
- <FormMessage />
- </FormItem>
- )}
- />
-
- {/* 입찰유형 */}
- <FormField
- control={form.control}
- name="biddingType"
- render={({ field }) => (
- <FormItem>
- <FormLabel>
- 입찰유형 <span className="text-red-500">*</span>
- </FormLabel>
- <Select onValueChange={field.onChange} defaultValue={field.value}>
- <FormControl>
- <SelectTrigger>
- <SelectValue placeholder="입찰유형 선택" />
- </SelectTrigger>
- </FormControl>
- <SelectContent>
- {Object.entries(biddingTypeLabels).map(([value, label]) => (
- <SelectItem key={value} value={value}>
- {label}
- </SelectItem>
- ))}
- </SelectContent>
- </Select>
- <FormMessage />
- </FormItem>
- )}
- />
-
- {/* 기타 입찰유형 직접입력 */}
- {form.watch("biddingType") === "other" && (
- <FormField
- control={form.control}
- name="biddingTypeCustom"
- render={({ field }) => (
- <FormItem>
- <FormLabel>
- 기타 입찰유형 <span className="text-red-500">*</span>
- </FormLabel>
- <FormControl>
- <Input
- placeholder="직접 입력하세요"
- {...field}
- />
- </FormControl>
- <FormMessage />
- </FormItem>
- )}
- />
- )}
- </div>
-
- <div className="grid grid-cols-2 gap-6">
- {/* 낙찰수 */}
- <FormField
- control={form.control}
- name="awardCount"
- render={({ field }) => (
- <FormItem>
- <FormLabel>
- 낙찰수 <span className="text-red-500">*</span>
- </FormLabel>
- <Select onValueChange={field.onChange} defaultValue={field.value}>
- <FormControl>
- <SelectTrigger>
- <SelectValue placeholder="낙찰수 선택" />
- </SelectTrigger>
- </FormControl>
- <SelectContent>
- {Object.entries(awardCountLabels).map(([value, label]) => (
- <SelectItem key={value} value={value}>
- {label}
- </SelectItem>
- ))}
- </SelectContent>
- </Select>
- <FormMessage />
- </FormItem>
- )}
- />
-
- {/* 계약 시작일 */}
- <FormField
- control={form.control}
- name="contractStartDate"
- render={({ field }) => (
- <FormItem>
- <FormLabel>계약 시작일 <span className="text-red-500">*</span></FormLabel>
- <FormControl>
- <Input
- type="date"
- {...field}
- />
- </FormControl>
- <FormMessage />
- </FormItem>
- )}
- />
-
- {/* 계약 종료일 */}
- <FormField
- control={form.control}
- name="contractEndDate"
- render={({ field }) => (
- <FormItem>
- <FormLabel>계약 종료일 <span className="text-red-500">*</span></FormLabel>
- <FormControl>
- <Input
- type="date"
- {...field}
- />
- </FormControl>
- <FormMessage />
- </FormItem>
- )}
- />
- </div>
-
- {/* 통화 선택만 유지 */}
- <FormField
- control={form.control}
- name="currency"
- render={({ field }) => (
- <FormItem>
- <FormLabel>
- 통화 <span className="text-red-500">*</span>
- </FormLabel>
- <Select onValueChange={field.onChange} defaultValue={field.value}>
- <FormControl>
- <SelectTrigger>
- <SelectValue placeholder="통화 선택" />
- </SelectTrigger>
- </FormControl>
- <SelectContent>
- <SelectItem value="KRW">KRW (원)</SelectItem>
- <SelectItem value="USD">USD (달러)</SelectItem>
- <SelectItem value="EUR">EUR (유로)</SelectItem>
- <SelectItem value="JPY">JPY (엔)</SelectItem>
- </SelectContent>
- </Select>
- <FormMessage />
- </FormItem>
- )}
- />
-
- {/* 입찰 조건 섹션 */}
- <Card>
- <CardHeader>
- <CardTitle>입찰 조건</CardTitle>
- <p className="text-sm text-muted-foreground">
- 벤더가 사전견적 시 참고할 입찰 조건을 설정하세요
- </p>
- </CardHeader>
- <CardContent className="space-y-6">
- <div className="grid grid-cols-2 gap-6">
- <div className="space-y-2">
- <label className="text-sm font-medium">
- 지급조건 <span className="text-red-500">*</span>
- </label>
- <Select
- value={biddingConditions.paymentTerms}
- onValueChange={(value) => setBiddingConditions(prev => ({
- ...prev,
- paymentTerms: value
- }))}
- >
- <SelectTrigger>
- <SelectValue placeholder="지급조건 선택" />
- </SelectTrigger>
- <SelectContent>
- {paymentTermsOptions.length > 0 ? (
- paymentTermsOptions.map((option) => (
- <SelectItem key={option.code} value={option.code}>
- {option.code} {option.description && `(${option.description})`}
- </SelectItem>
- ))
- ) : (
- <SelectItem value="loading" disabled>
- 데이터를 불러오는 중...
- </SelectItem>
- )}
- </SelectContent>
- </Select>
- </div>
-
- <div className="space-y-2">
- <label className="text-sm font-medium">
- 세금조건 <span className="text-red-500">*</span>
- </label>
- <Select
- value={biddingConditions.taxConditions}
- onValueChange={(value) => setBiddingConditions(prev => ({
- ...prev,
- taxConditions: value
- }))}
- >
- <SelectTrigger>
- <SelectValue placeholder="세금조건 선택" />
- </SelectTrigger>
- <SelectContent>
- {TAX_CONDITIONS.map((condition) => (
- <SelectItem key={condition.code} value={condition.code}>
- {condition.name}
- </SelectItem>
- ))}
- </SelectContent>
- </Select>
- </div>
-
- <div className="space-y-2">
- <label className="text-sm font-medium">
- 운송조건(인코텀즈) <span className="text-red-500">*</span>
- </label>
- <Select
- value={biddingConditions.incoterms}
- onValueChange={(value) => setBiddingConditions(prev => ({
- ...prev,
- incoterms: value
- }))}
- >
- <SelectTrigger>
- <SelectValue placeholder="인코텀즈 선택" />
- </SelectTrigger>
- <SelectContent>
- {incotermsOptions.length > 0 ? (
- incotermsOptions.map((option) => (
- <SelectItem key={option.code} value={option.code}>
- {option.code} {option.description && `(${option.description})`}
- </SelectItem>
- ))
- ) : (
- <SelectItem value="loading" disabled>
- 데이터를 불러오는 중...
- </SelectItem>
- )}
- </SelectContent>
- </Select>
- </div>
-
- <div className="space-y-2">
- <label className="text-sm font-medium">
- 계약 납품일 <span className="text-red-500">*</span>
- </label>
- <Input
- type="date"
- value={biddingConditions.contractDeliveryDate}
- onChange={(e) => setBiddingConditions(prev => ({
- ...prev,
- contractDeliveryDate: e.target.value
- }))}
- />
- </div>
-
- <div className="space-y-2">
- <label className="text-sm font-medium">선적지 (선택사항)</label>
- <Select
- value={biddingConditions.shippingPort}
- onValueChange={(value) => setBiddingConditions(prev => ({
- ...prev,
- shippingPort: value
- }))}
- >
- <SelectTrigger>
- <SelectValue placeholder="선적지 선택" />
- </SelectTrigger>
- <SelectContent>
- {shippingPlaces.length > 0 ? (
- shippingPlaces.map((place) => (
- <SelectItem key={place.code} value={place.code}>
- {place.code} {place.description && `(${place.description})`}
- </SelectItem>
- ))
- ) : (
- <SelectItem value="loading" disabled>
- 데이터를 불러오는 중...
- </SelectItem>
- )}
- </SelectContent>
- </Select>
- </div>
-
- <div className="space-y-2">
- <label className="text-sm font-medium">하역지 (선택사항)</label>
- <Select
- value={biddingConditions.destinationPort}
- onValueChange={(value) => setBiddingConditions(prev => ({
- ...prev,
- destinationPort: value
- }))}
- >
- <SelectTrigger>
- <SelectValue placeholder="하역지 선택" />
- </SelectTrigger>
- <SelectContent>
- {destinationPlaces.length > 0 ? (
- destinationPlaces.map((place) => (
- <SelectItem key={place.code} value={place.code}>
- {place.code} {place.description && `(${place.description})`}
- </SelectItem>
- ))
- ) : (
- <SelectItem value="loading" disabled>
- 데이터를 불러오는 중...
- </SelectItem>
- )}
- </SelectContent>
- </Select>
- </div>
- </div>
-
- <div className="flex items-center space-x-2">
- <Switch
- id="price-adjustment"
- checked={biddingConditions.isPriceAdjustmentApplicable}
- onCheckedChange={(checked) => setBiddingConditions(prev => ({
- ...prev,
- isPriceAdjustmentApplicable: checked
- }))}
- />
- <label htmlFor="price-adjustment" className="text-sm font-medium">
- 연동제 적용 요건 문의
- </label>
- </div>
-
- <div className="space-y-2">
- <label className="text-sm font-medium">스페어파트 옵션</label>
- <Textarea
- placeholder="스페어파트 관련 옵션을 입력하세요"
- value={biddingConditions.sparePartOptions}
- onChange={(e) => setBiddingConditions(prev => ({
- ...prev,
- sparePartOptions: e.target.value
- }))}
- rows={3}
- />
- </div>
- </CardContent>
- </Card>
- </CardContent>
- </Card>
- </TabsContent>
-
-
- {/* 일정 & 회의 탭 */}
- <TabsContent value="schedule" className="mt-0 space-y-6">
- <Card>
- <CardHeader>
- <CardTitle>일정 정보</CardTitle>
- </CardHeader>
- <CardContent className="space-y-6">
- <div className="grid grid-cols-2 gap-6">
- {/* 제출시작일시 */}
- <FormField
- control={form.control}
- name="submissionStartDate"
- render={({ field }) => (
- <FormItem>
- <FormLabel>
- 제출시작일시 <span className="text-red-500">*</span>
- </FormLabel>
- <FormControl>
- <Input
- type="datetime-local"
- {...field}
- />
- </FormControl>
- <FormMessage />
- </FormItem>
- )}
- />
-
- {/* 제출마감일시 */}
- <FormField
- control={form.control}
- name="submissionEndDate"
- render={({ field }) => (
- <FormItem>
- <FormLabel>
- 제출마감일시 <span className="text-red-500">*</span>
- </FormLabel>
- <FormControl>
- <Input
- type="datetime-local"
- {...field}
- />
- </FormControl>
- <FormMessage />
- </FormItem>
- )}
- />
- </div>
- </CardContent>
- </Card>
-
- {/* 사양설명회 */}
- <Card>
- <CardHeader>
- <CardTitle>사양설명회</CardTitle>
- </CardHeader>
- <CardContent className="space-y-6">
- <FormField
- control={form.control}
- name="hasSpecificationMeeting"
- render={({ field }) => (
- <FormItem className="flex flex-row items-center justify-between rounded-lg border p-4">
- <div className="space-y-0.5">
- <FormLabel className="text-base">
- 사양설명회 실시
- </FormLabel>
- <FormDescription>
- 사양설명회를 실시할 경우 상세 정보를 입력하세요
- </FormDescription>
- </div>
- <FormControl>
- <Switch
- checked={field.value}
- onCheckedChange={field.onChange}
- />
- </FormControl>
- </FormItem>
- )}
- />
-
- {/* 사양설명회 정보 (조건부 표시) */}
- {form.watch("hasSpecificationMeeting") && (
- <div className="space-y-6 p-4 border rounded-lg bg-muted/50">
- <div className="grid grid-cols-2 gap-4">
- <div>
- <label className="text-sm font-medium">
- 회의일시 <span className="text-red-500">*</span>
- </label>
- <Input
- type="datetime-local"
- value={specMeetingInfo.meetingDate}
- onChange={(e) => setSpecMeetingInfo(prev => ({ ...prev, meetingDate: e.target.value }))}
- className={!specMeetingInfo.meetingDate ? 'border-red-200' : ''}
- />
- {!specMeetingInfo.meetingDate && (
- <p className="text-sm text-red-500 mt-1">회의일시는 필수입니다</p>
- )}
- </div>
- <div>
- <label className="text-sm font-medium">회의시간</label>
- <Input
- placeholder="예: 14:00 ~ 16:00"
- value={specMeetingInfo.meetingTime}
- onChange={(e) => setSpecMeetingInfo(prev => ({ ...prev, meetingTime: e.target.value }))}
- />
- </div>
- </div>
-
- <div>
- <label className="text-sm font-medium">
- 장소 <span className="text-red-500">*</span>
- </label>
- <Input
- placeholder="회의 장소"
- value={specMeetingInfo.location}
- onChange={(e) => setSpecMeetingInfo(prev => ({ ...prev, location: e.target.value }))}
- className={!specMeetingInfo.location ? 'border-red-200' : ''}
- />
- {!specMeetingInfo.location && (
- <p className="text-sm text-red-500 mt-1">회의 장소는 필수입니다</p>
- )}
- </div>
-
- <div>
- <label className="text-sm font-medium">주소</label>
- <Textarea
- placeholder="상세 주소"
- value={specMeetingInfo.address}
- onChange={(e) => setSpecMeetingInfo(prev => ({ ...prev, address: e.target.value }))}
- />
- </div>
-
- <div className="grid grid-cols-3 gap-4">
- <div>
- <label className="text-sm font-medium">
- 담당자 <span className="text-red-500">*</span>
- </label>
- <Input
- placeholder="담당자명"
- value={specMeetingInfo.contactPerson}
- onChange={(e) => setSpecMeetingInfo(prev => ({ ...prev, contactPerson: e.target.value }))}
- className={!specMeetingInfo.contactPerson ? 'border-red-200' : ''}
- />
- {!specMeetingInfo.contactPerson && (
- <p className="text-sm text-red-500 mt-1">담당자는 필수입니다</p>
- )}
- </div>
- <div>
- <label className="text-sm font-medium">연락처</label>
- <Input
- placeholder="전화번호"
- value={specMeetingInfo.contactPhone}
- onChange={(e) => setSpecMeetingInfo(prev => ({ ...prev, contactPhone: e.target.value }))}
- />
- </div>
- <div>
- <label className="text-sm font-medium">이메일</label>
- <Input
- type="email"
- placeholder="이메일"
- value={specMeetingInfo.contactEmail}
- onChange={(e) => setSpecMeetingInfo(prev => ({ ...prev, contactEmail: e.target.value }))}
- />
- </div>
- </div>
-
- <div className="grid grid-cols-2 gap-4">
- <div>
- <label className="text-sm font-medium">회의 안건</label>
- <Textarea
- placeholder="회의 안건"
- value={specMeetingInfo.agenda}
- onChange={(e) => setSpecMeetingInfo(prev => ({ ...prev, agenda: e.target.value }))}
- />
- </div>
- <div>
- <label className="text-sm font-medium">준비물 & 특이사항</label>
- <Textarea
- placeholder="준비물 및 특이사항"
- value={specMeetingInfo.materials}
- onChange={(e) => setSpecMeetingInfo(prev => ({ ...prev, materials: e.target.value }))}
- />
- </div>
- </div>
-
- <div className="flex items-center space-x-2">
- <Switch
- id="required-meeting"
- checked={specMeetingInfo.isRequired}
- onCheckedChange={(checked) => setSpecMeetingInfo(prev => ({ ...prev, isRequired: checked }))}
- />
- <label htmlFor="required-meeting" className="text-sm font-medium">
- 필수 참석
- </label>
- </div>
-
- {/* 사양설명회 첨부 파일 */}
- <div className="space-y-4">
- <label className="text-sm font-medium">사양설명회 관련 첨부 파일</label>
- <Dropzone
- onDrop={addMeetingFiles}
- accept={{
- 'application/pdf': ['.pdf'],
- 'application/msword': ['.doc'],
- 'application/vnd.openxmlformats-officedocument.wordprocessingml.document': ['.docx'],
- 'application/vnd.ms-excel': ['.xls'],
- 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet': ['.xlsx'],
- 'image/*': ['.png', '.jpg', '.jpeg'],
- }}
- multiple
- className="border-2 border-dashed border-gray-300 rounded-lg p-6 text-center hover:border-gray-400 transition-colors"
- >
- <DropzoneZone>
- <DropzoneUploadIcon />
- <DropzoneTitle>사양설명회 관련 문서 업로드</DropzoneTitle>
- <DropzoneDescription>
- 안내문, 도면, 자료 등을 업로드하세요 (PDF, Word, Excel, 이미지 파일 지원)
- </DropzoneDescription>
- </DropzoneZone>
- <DropzoneInput />
- </Dropzone>
-
- {specMeetingInfo.meetingFiles.length > 0 && (
- <FileList className="mt-4">
- <FileListHeader>
- <span>업로드된 파일 ({specMeetingInfo.meetingFiles.length})</span>
- </FileListHeader>
- {specMeetingInfo.meetingFiles.map((file, fileIndex) => (
- <FileListItem key={fileIndex}>
- <FileListIcon />
- <FileListInfo>
- <FileListName>{file.name}</FileListName>
- <FileListSize>{file.size}</FileListSize>
- </FileListInfo>
- <FileListAction>
- <Button
- type="button"
- variant="outline"
- size="sm"
- onClick={() => removeMeetingFile(fileIndex)}
- >
- 삭제
- </Button>
- </FileListAction>
- </FileListItem>
- ))}
- </FileList>
- )}
- </div>
- </div>
- )}
- </CardContent>
- </Card>
-
- {/* 긴급 입찰 설정 */}
- <Card>
- <CardHeader>
- <CardTitle>긴급 입찰 설정</CardTitle>
- </CardHeader>
- <CardContent className="space-y-6">
- <FormField
- control={form.control}
- name="isUrgent"
- render={({ field }) => (
- <FormItem className="flex flex-row items-center justify-between rounded-lg border p-4">
- <div className="space-y-0.5">
- <FormLabel className="text-base">
- 긴급 입찰
- </FormLabel>
- <FormDescription>
- 긴급 입찰 여부를 설정합니다
- </FormDescription>
- </div>
- <FormControl>
- <Switch
- checked={field.value}
- onCheckedChange={field.onChange}
- />
- </FormControl>
- </FormItem>
- )}
- />
- </CardContent>
- </Card>
- </TabsContent>
-
-
- {/* 세부내역 탭 */}
- <TabsContent value="details" className="mt-0 space-y-6">
- <Card>
- <CardHeader className="flex flex-row items-center justify-between">
- <div>
- <CardTitle>세부내역 관리</CardTitle>
- <p className="text-sm text-muted-foreground mt-1">
- 최소 하나의 품목을 입력해야 합니다
- </p>
- <p className="text-xs text-amber-600 mt-1">
- 수량/단위 또는 중량/중량단위를 선택해서 입력하세요
- </p>
- </div>
- <Button
- type="button"
- variant="outline"
- onClick={addPRItem}
- className="flex items-center gap-2"
- >
- <Plus className="h-4 w-4" />
- 아이템 추가
- </Button>
- </CardHeader>
- <CardContent className="space-y-6">
- {/* 아이템 테이블 */}
- {prItems.length > 0 ? (
- <div className="space-y-4">
- <div className="border rounded-lg">
- <Table>
- <TableHeader>
- <TableRow>
- <TableHead className="w-[60px]">대표</TableHead>
- <TableHead className="w-[120px]">PR 번호</TableHead>
- <TableHead className="w-[120px]">품목코드</TableHead>
- <TableHead>품목정보 *</TableHead>
- <TableHead className="w-[80px]">수량</TableHead>
- <TableHead className="w-[80px]">단위</TableHead>
- <TableHead className="w-[80px]">중량</TableHead>
- <TableHead className="w-[80px]">중량단위</TableHead>
- <TableHead className="w-[140px]">납품요청일</TableHead>
- <TableHead className="w-[80px]">스펙파일</TableHead>
- <TableHead className="w-[80px]">액션</TableHead>
- </TableRow>
- </TableHeader>
- <TableBody>
- {prItems.map((item, index) => (
- <TableRow key={item.id}>
- <TableCell>
- <div className="flex justify-center">
- <Checkbox
- checked={item.isRepresentative}
- onCheckedChange={() => setRepresentativeItem(item.id)}
- />
- </div>
- </TableCell>
- <TableCell>
- <Input
- placeholder="PR 번호"
- value={item.prNumber}
- onChange={(e) => updatePRItem(item.id, { prNumber: e.target.value })}
- className="h-8"
- />
- </TableCell>
- <TableCell>
- <Input
- placeholder={`ITEM-${index + 1}`}
- value={item.itemCode}
- onChange={(e) => updatePRItem(item.id, { itemCode: e.target.value })}
- className="h-8"
- />
- </TableCell>
- <TableCell>
- <Input
- placeholder="품목정보 *"
- value={item.itemInfo}
- onChange={(e) => updatePRItem(item.id, { itemInfo: e.target.value })}
- className="h-8"
- />
- </TableCell>
- <TableCell>
- <Input
- type="number"
- min="0"
- placeholder="수량"
- value={item.quantity}
- onChange={(e) => updatePRItem(item.id, { quantity: e.target.value })}
- className="h-8"
- />
- </TableCell>
- <TableCell>
- <Select
- value={item.quantityUnit}
- onValueChange={(value) => updatePRItem(item.id, { quantityUnit: value })}
- >
- <SelectTrigger className="h-8">
- <SelectValue />
- </SelectTrigger>
- <SelectContent>
- <SelectItem value="EA">EA</SelectItem>
- <SelectItem value="SET">SET</SelectItem>
- <SelectItem value="LOT">LOT</SelectItem>
- <SelectItem value="M">M</SelectItem>
- <SelectItem value="M2">M²</SelectItem>
- <SelectItem value="M3">M³</SelectItem>
- </SelectContent>
- </Select>
- </TableCell>
- <TableCell>
- <Input
- type="number"
- min="0"
- placeholder="중량"
- value={item.totalWeight}
- onChange={(e) => updatePRItem(item.id, { totalWeight: e.target.value })}
- className="h-8"
- />
- </TableCell>
- <TableCell>
- <Select
- value={item.weightUnit}
- onValueChange={(value) => updatePRItem(item.id, { weightUnit: value })}
- >
- <SelectTrigger className="h-8">
- <SelectValue />
- </SelectTrigger>
- <SelectContent>
- <SelectItem value="KG">KG</SelectItem>
- <SelectItem value="TON">TON</SelectItem>
- <SelectItem value="G">G</SelectItem>
- <SelectItem value="LB">LB</SelectItem>
- </SelectContent>
- </Select>
- </TableCell>
- <TableCell>
- <Input
- type="date"
- value={item.requestedDeliveryDate}
- onChange={(e) => updatePRItem(item.id, { requestedDeliveryDate: e.target.value })}
- className="h-8"
- placeholder="납품요청일"
- />
- </TableCell>
- <TableCell>
- <div className="flex items-center gap-2">
- <Button
- type="button"
- variant={selectedItemForFile === item.id ? "default" : "outline"}
- size="sm"
- onClick={() => setSelectedItemForFile(selectedItemForFile === item.id ? null : item.id)}
- className="h-8 w-8 p-0"
- >
- <Paperclip className="h-4 w-4" />
- </Button>
- <span className="text-sm">{item.specFiles.length}</span>
- </div>
- </TableCell>
- <TableCell>
- <Button
- type="button"
- variant="outline"
- size="sm"
- onClick={() => removePRItem(item.id)}
- disabled={prItems.length <= 1}
- className="h-8 w-8 p-0"
- title={prItems.length <= 1 ? "최소 하나의 품목이 필요합니다" : "품목 삭제"}
- >
- <Trash2 className="h-4 w-4" />
- </Button>
- </TableCell>
- </TableRow>
- ))}
- </TableBody>
- </Table>
- </div>
-
- {/* 대표 아이템 정보 표시 */}
- {representativePrNumber && (
- <div className="flex items-center gap-2 p-3 bg-blue-50 border border-blue-200 rounded-lg">
- <CheckCircle2 className="h-4 w-4 text-blue-600" />
- <span className="text-sm text-blue-800">
- 대표 PR 번호: <strong>{representativePrNumber}</strong>
- </span>
- </div>
- )}
-
- {/* 선택된 아이템의 파일 업로드 */}
- {selectedItemForFile && (
- <div className="space-y-4 p-4 border rounded-lg bg-muted/50">
- {(() => {
- const selectedItem = prItems.find(item => item.id === selectedItemForFile)
- return (
- <>
- <div className="flex items-center justify-between">
- <h6 className="font-medium text-sm">
- {selectedItem?.itemInfo || selectedItem?.itemCode || "선택된 아이템"}의 스펙 파일
- </h6>
- <Button
- type="button"
- variant="ghost"
- size="sm"
- onClick={() => setSelectedItemForFile(null)}
- >
- 닫기
- </Button>
- </div>
-
- <Dropzone
- onDrop={(files) => addSpecFiles(selectedItemForFile, files)}
- accept={{
- 'application/pdf': ['.pdf'],
- 'application/msword': ['.doc'],
- 'application/vnd.openxmlformats-officedocument.wordprocessingml.document': ['.docx'],
- 'application/vnd.ms-excel': ['.xls'],
- 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet': ['.xlsx'],
- }}
- multiple
- className="border-2 border-dashed border-gray-300 rounded-lg p-4 text-center hover:border-gray-400 transition-colors"
- >
- <DropzoneZone>
- <DropzoneUploadIcon />
- <DropzoneTitle>스펙 문서 업로드</DropzoneTitle>
- <DropzoneDescription>
- PDF, Word, Excel 파일을 드래그하거나 클릭하여 선택
- </DropzoneDescription>
- </DropzoneZone>
- <DropzoneInput />
- </Dropzone>
-
- {selectedItem && selectedItem.specFiles.length > 0 && (
- <FileList className="mt-4">
- <FileListHeader>
- <span>업로드된 파일 ({selectedItem.specFiles.length})</span>
- </FileListHeader>
- {selectedItem.specFiles.map((file, fileIndex) => (
- <FileListItem
- key={fileIndex}
- className="flex items-center justify-between p-3 border rounded-lg mb-2"
- >
- <div className="flex items-center gap-3 flex-1">
- <FileListIcon className="flex-shrink-0" />
- <FileListInfo className="flex items-center gap-3 flex-1">
- <FileListName className="font-medium text-gray-700">
- {file.name}
- </FileListName>
- <FileListSize className="text-sm text-gray-500">
- {file.size}
- </FileListSize>
- </FileListInfo>
- </div>
- <FileListAction className="flex-shrink-0">
- <Button
- type="button"
- variant="outline"
- size="sm"
- onClick={() => removeSpecFile(selectedItemForFile, fileIndex)}
- >
- 삭제
- </Button>
- </FileListAction>
- </FileListItem>
- ))}
- </FileList>
- )}
- </>
- )
- })()}
- </div>
- )}
- </div>
- ) : (
- <div className="text-center py-12 border-2 border-dashed border-gray-300 rounded-lg">
- <FileText className="h-12 w-12 text-gray-400 mx-auto mb-4" />
- <p className="text-gray-500 mb-2">아직 아이템이 없습니다</p>
- <p className="text-sm text-gray-400 mb-4">
- PR 아이템이나 수기 아이템을 추가하여 입찰 세부내역을 작성하세요
- </p>
- <Button
- type="button"
- variant="outline"
- onClick={addPRItem}
- className="flex items-center gap-2 mx-auto"
- >
- <Plus className="h-4 w-4" />
- 첫 번째 아이템 추가
- </Button>
- </div>
- )}
- </CardContent>
- </Card>
- </TabsContent>
-
- {/* 담당자 & 기타 탭 */}
- <TabsContent value="manager" className="mt-0 space-y-6">
- {/* 담당자 정보 */}
- <Card>
- <CardHeader>
- <CardTitle>담당자 정보</CardTitle>
- </CardHeader>
- <CardContent className="space-y-6">
- <FormField
- control={form.control}
- name="managerName"
- render={({ field }) => (
- <FormItem>
- <FormLabel>담당자명</FormLabel>
- <FormControl>
- <Input
- placeholder="담당자명"
- {...field}
- />
- </FormControl>
- <FormDescription>
- 현재 로그인한 사용자 정보로 자동 설정됩니다.
- </FormDescription>
- <FormMessage />
- </FormItem>
- )}
- />
-
- <div className="grid grid-cols-2 gap-6">
- <FormField
- control={form.control}
- name="managerEmail"
- render={({ field }) => (
- <FormItem>
- <FormLabel>담당자 이메일</FormLabel>
- <FormControl>
- <Input
- type="email"
- placeholder="email@example.com"
- {...field}
- />
- </FormControl>
- <FormMessage />
- </FormItem>
- )}
- />
-
- <FormField
- control={form.control}
- name="managerPhone"
- render={({ field }) => (
- <FormItem>
- <FormLabel>담당자 전화번호</FormLabel>
- <FormControl>
- <Input
- placeholder="010-1234-5678"
- {...field}
- />
- </FormControl>
- <FormMessage />
- </FormItem>
- )}
- />
- </div>
- </CardContent>
- </Card>
-
- {/* 기타 설정 */}
- <Card>
- <CardHeader>
- <CardTitle>기타 설정</CardTitle>
- </CardHeader>
- <CardContent className="space-y-6">
- <FormField
- control={form.control}
- name="isPublic"
- render={({ field }) => (
- <FormItem className="flex flex-row items-center justify-between rounded-lg border p-4">
- <div className="space-y-0.5">
- <FormLabel className="text-base">
- 공개 입찰
- </FormLabel>
- <FormDescription>
- 공개 입찰 여부를 설정합니다
- </FormDescription>
- </div>
- <FormControl>
- <Switch
- checked={field.value}
- onCheckedChange={field.onChange}
- />
- </FormControl>
- </FormItem>
- )}
- />
-
-
- <FormField
- control={form.control}
- name="remarks"
- render={({ field }) => (
- <FormItem>
- <FormLabel>비고</FormLabel>
- <FormControl>
- <Textarea
- placeholder="추가 메모나 특이사항을 입력하세요"
- rows={4}
- {...field}
- />
- </FormControl>
- <FormMessage />
- </FormItem>
- )}
- />
- </CardContent>
- </Card>
-
- {/* 입찰 생성 요약 */}
- {/* <Card>
- <CardHeader>
- <CardTitle>입찰 생성 요약</CardTitle>
- </CardHeader>
- <CardContent className="space-y-4">
- <div className="grid grid-cols-2 gap-4 text-sm">
- <div>
- <span className="font-medium">프로젝트:</span>
- <p className="text-muted-foreground">
- {form.watch("projectName") || "선택되지 않음"}
- </p>
- </div>
- <div>
- <span className="font-medium">입찰명:</span>
- <p className="text-muted-foreground">
- {form.watch("title") || "입력되지 않음"}
- </p>
- </div>
- <div>
- <span className="font-medium">계약구분:</span>
- <p className="text-muted-foreground">
- {contractTypeLabels[form.watch("contractType") as keyof typeof contractTypeLabels] || "선택되지 않음"}
- </p>
- </div>
- <div>
- <span className="font-medium">입찰유형:</span>
- <p className="text-muted-foreground">
- {biddingTypeLabels[form.watch("biddingType") as keyof typeof biddingTypeLabels] || "선택되지 않음"}
- </p>
- </div>
- <div>
- <span className="font-medium">사양설명회:</span>
- <p className="text-muted-foreground">
- {form.watch("hasSpecificationMeeting") ? "실시함" : "실시하지 않음"}
- </p>
- </div>
- <div>
- <span className="font-medium">대표 PR 번호:</span>
- <p className="text-muted-foreground">
- {representativePrNumber || "설정되지 않음"}
- </p>
- </div>
- <div>
- <span className="font-medium">세부 아이템:</span>
- <p className="text-muted-foreground">
- {prItems.length}개 아이템
- </p>
- </div>
- <div>
- <span className="font-medium">사양설명회 파일:</span>
- <p className="text-muted-foreground">
- {specMeetingInfo.meetingFiles.length}개 파일
- </p>
- </div>
- </div>
- </CardContent>
- </Card> */}
- </TabsContent>
-
- </div>
- </Tabs>
+ {tab === 'basic' && '기본 정보'}
+ {tab === 'schedule' && '입찰 계획'}
+ {tab === 'details' && '세부 내역'}
+ {tab === 'manager' && '담당자'}
+ {!tabValidation[tab].isValid && (
+ <span className="absolute -top-1 -right-1 h-2 w-2 bg-red-500 rounded-full"></span>
+ )}
+ </button>
+ ))}
+ </div>
+ </div>
+
+ {/* 탭 콘텐츠 */}
+ <div className="flex-1 overflow-y-auto p-6">
+ <TabsContent value="basic" className="mt-0 space-y-6">
+ <Card>
+ <CardHeader>
+ <CardTitle>기본 정보 및 계약 정보</CardTitle>
+ </CardHeader>
+ <CardContent className="space-y-6">
+ <FormField
+ control={form.control}
+ name="title"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>입찰명 <span className="text-red-500">*</span></FormLabel>
+ <FormControl>
+ <Input placeholder="입찰명을 입력하세요" {...field} />
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+ <FormField
+ control={form.control}
+ name="description"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>입찰개요</FormLabel>
+ <FormControl>
+ <Textarea placeholder="입찰에 대한 설명을 입력하세요" rows={4} {...field} />
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+ <div className="grid grid-cols-2 gap-6">
+ <FormField
+ control={form.control}
+ name="contractType"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>계약구분 <span className="text-red-500">*</span></FormLabel>
+ <Select onValueChange={field.onChange} defaultValue={field.value}>
+ <FormControl>
+ <SelectTrigger>
+ <SelectValue placeholder="계약구분 선택" />
+ </SelectTrigger>
+ </FormControl>
+ <SelectContent>
+ {Object.entries(contractTypeLabels).map(([value, label]) => (
+ <SelectItem key={value} value={value}>
+ {label}
+ </SelectItem>
+ ))}
+ </SelectContent>
+ </Select>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+ <FormField
+ control={form.control}
+ name="biddingType"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>입찰유형 <span className="text-red-500">*</span></FormLabel>
+ <Select onValueChange={field.onChange} defaultValue={field.value}>
+ <FormControl>
+ <SelectTrigger>
+ <SelectValue placeholder="입찰유형 선택" />
+ </SelectTrigger>
+ </FormControl>
+ <SelectContent>
+ {Object.entries(biddingTypeLabels).map(([value, label]) => (
+ <SelectItem key={value} value={value}>
+ {label}
+ </SelectItem>
+ ))}
+ </SelectContent>
+ </Select>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+ {form.watch('biddingType') === 'other' && (
+ <FormField
+ control={form.control}
+ name="biddingTypeCustom"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>기타 입찰유형 <span className="text-red-500">*</span></FormLabel>
+ <FormControl>
+ <Input placeholder="직접 입력하세요" {...field} />
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+ )}
+ </div>
+ <div className="grid grid-cols-2 gap-6">
+ <FormField
+ control={form.control}
+ name="awardCount"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>낙찰수 <span className="text-red-500">*</span></FormLabel>
+ <Select onValueChange={field.onChange} defaultValue={field.value}>
+ <FormControl>
+ <SelectTrigger>
+ <SelectValue placeholder="낙찰수 선택" />
+ </SelectTrigger>
+ </FormControl>
+ <SelectContent>
+ {Object.entries(awardCountLabels).map(([value, label]) => (
+ <SelectItem key={value} value={value}>
+ {label}
+ </SelectItem>
+ ))}
+ </SelectContent>
+ </Select>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+ <FormField
+ control={form.control}
+ name="currency"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>통화 <span className="text-red-500">*</span></FormLabel>
+ <Select onValueChange={field.onChange} defaultValue={field.value}>
+ <FormControl>
+ <SelectTrigger>
+ <SelectValue placeholder="통화 선택" />
+ </SelectTrigger>
+ </FormControl>
+ <SelectContent>
+ <SelectItem value="KRW">KRW (원)</SelectItem>
+ <SelectItem value="USD">USD (달러)</SelectItem>
+ <SelectItem value="EUR">EUR (유로)</SelectItem>
+ <SelectItem value="JPY">JPY (엔)</SelectItem>
+ </SelectContent>
+ </Select>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+ </div>
+ <div className="grid grid-cols-2 gap-6">
+ <FormField
+ control={form.control}
+ name="contractStartDate"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>계약시작일</FormLabel>
+ <FormControl>
+ <Input type="date" {...field} />
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+ <FormField
+ control={form.control}
+ name="contractEndDate"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>계약종료일</FormLabel>
+ <FormControl>
+ <Input type="date" {...field} />
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+ </div>
+ <FormField
+ control={form.control}
+ name="purchasingOrganization"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>구매조직</FormLabel>
+ <Select onValueChange={field.onChange} defaultValue={field.value}>
+ <FormControl>
+ <SelectTrigger>
+ <SelectValue placeholder="구매조직 선택" />
+ </SelectTrigger>
+ </FormControl>
+ <SelectContent>
+ <SelectItem value="조선">조선</SelectItem>
+ <SelectItem value="해양">해양</SelectItem>
+ <SelectItem value="기타">기타</SelectItem>
+ </SelectContent>
+ </Select>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+ </CardContent>
+ </Card>
+ </TabsContent>
+
+ <TabsContent value="schedule" className="mt-0 space-y-6">
+ <Card>
+ <CardHeader>
+ <CardTitle>일정 정보</CardTitle>
+ </CardHeader>
+ <CardContent className="space-y-6">
+ <div className="grid grid-cols-2 gap-6">
+ <FormField
+ control={form.control}
+ name="submissionStartDate"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>제출시작일시 <span className="text-red-500">*</span></FormLabel>
+ <FormControl>
+ <Input type="datetime-local" {...field} />
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+ <FormField
+ control={form.control}
+ name="submissionEndDate"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>제출마감일시 <span className="text-red-500">*</span></FormLabel>
+ <FormControl>
+ <Input type="datetime-local" {...field} />
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+ </div>
+ <FormField
+ control={form.control}
+ name="hasSpecificationMeeting"
+ render={({ field }) => (
+ <FormItem className="flex flex-row items-center justify-between rounded-lg border p-4">
+ <div className="space-y-0.5">
+ <FormLabel className="text-base">사양설명회 실시</FormLabel>
+ <FormDescription>
+ 사양설명회를 실시할 경우 상세 정보를 입력하세요
+ </FormDescription>
+ </div>
+ <FormControl>
+ <Switch checked={field.value} onCheckedChange={field.onChange} />
+ </FormControl>
+ </FormItem>
+ )}
+ />
+ {form.watch('hasSpecificationMeeting') && (
+ <div className="space-y-6 p-4 border rounded-lg bg-muted/50">
+ <div className="grid grid-cols-2 gap-4">
+ <div>
+ <Label>회의일시 <span className="text-red-500">*</span></Label>
+ <Input
+ type="datetime-local"
+ value={specMeetingInfo.meetingDate}
+ onChange={(e) => setSpecMeetingInfo((prev) => ({ ...prev, meetingDate: e.target.value }))}
+ className={!specMeetingInfo.meetingDate ? 'border-red-200' : ''}
+ />
+ {!specMeetingInfo.meetingDate && (
+ <p className="text-sm text-red-500 mt-1">회의일시는 필수입니다</p>
+ )}
+ </div>
+ <div>
+ <Label>회의시간</Label>
+ <Input
+ placeholder="예: 14:00 ~ 16:00"
+ value={specMeetingInfo.meetingTime}
+ onChange={(e) => setSpecMeetingInfo((prev) => ({ ...prev, meetingTime: e.target.value }))}
+ />
+ </div>
+ </div>
+ <div>
+ <Label>장소 <span className="text-red-500">*</span></Label>
+ <Input
+ placeholder="회의 장소"
+ value={specMeetingInfo.location}
+ onChange={(e) => setSpecMeetingInfo((prev) => ({ ...prev, location: e.target.value }))}
+ className={!specMeetingInfo.location ? 'border-red-200' : ''}
+ />
+ {!specMeetingInfo.location && (
+ <p className="text-sm text-red-500 mt-1">회의 장소는 필수입니다</p>
+ )}
+ </div>
+ <div className="grid grid-cols-3 gap-4">
+ <div>
+ <Label>담당자 <span className="text-red-500">*</span></Label>
+ <Input
+ placeholder="담당자명"
+ value={specMeetingInfo.contactPerson}
+ onChange={(e) => setSpecMeetingInfo((prev) => ({ ...prev, contactPerson: e.target.value }))}
+ className={!specMeetingInfo.contactPerson ? 'border-red-200' : ''}
+ />
+ {!specMeetingInfo.contactPerson && (
+ <p className="text-sm text-red-500 mt-1">담당자는 필수입니다</p>
+ )}
+ </div>
+ <div>
+ <Label>연락처</Label>
+ <Input
+ placeholder="전화번호"
+ value={specMeetingInfo.contactPhone}
+ onChange={(e) => setSpecMeetingInfo((prev) => ({ ...prev, contactPhone: e.target.value }))}
+ />
+ </div>
+ <div>
+ <Label>이메일</Label>
+ <Input
+ type="email"
+ placeholder="이메일"
+ value={specMeetingInfo.contactEmail}
+ onChange={(e) => setSpecMeetingInfo((prev) => ({ ...prev, contactEmail: e.target.value }))}
+ />
+ </div>
+ </div>
+ </div>
+ )}
+ </CardContent>
+ </Card>
+
+ {/* 입찰 조건 섹션 */}
+ <Card>
+ <CardHeader>
+ <CardTitle>입찰 조건</CardTitle>
+ <p className="text-sm text-muted-foreground">
+ 벤더가 사전견적 시 참고할 입찰 조건을 설정하세요
+ </p>
+ </CardHeader>
+ <CardContent className="space-y-6">
+ <div className="grid grid-cols-2 gap-6">
+ <div className="space-y-2">
+ <Label>
+ 지급조건 <span className="text-red-500">*</span>
+ </Label>
+ <Select
+ value={biddingConditions.paymentTerms}
+ onValueChange={(value) => setBiddingConditions(prev => ({
+ ...prev,
+ paymentTerms: value
+ }))}
+ >
+ <SelectTrigger>
+ <SelectValue placeholder="지급조건 선택" />
+ </SelectTrigger>
+ <SelectContent>
+ {paymentTermsOptions.length > 0 ? (
+ paymentTermsOptions.map((option) => (
+ <SelectItem key={option.code} value={option.code}>
+ {option.code} {option.description && `(${option.description})`}
+ </SelectItem>
+ ))
+ ) : (
+ <SelectItem value="loading" disabled>
+ 데이터를 불러오는 중...
+ </SelectItem>
+ )}
+ </SelectContent>
+ </Select>
</div>
- {/* 고정 버튼 영역 */}
- <div className="flex-shrink-0 border-t bg-background p-6">
- <div className="flex justify-between items-center">
- <div className="text-sm text-muted-foreground">
- {activeTab === "basic" && (
- <span>
- 기본 정보를 입력하세요
- {!tabValidation.basic.isValid && (
- <span className="text-red-500 ml-2">• 필수 항목이 누락되었습니다</span>
- )}
- </span>
- )}
- {activeTab === "contract" && (
- <span>
- 계약 및 가격 정보를 입력하세요
- {!tabValidation.contract.isValid && (
- <span className="text-red-500 ml-2">• 필수 항목이 누락되었습니다</span>
- )}
- </span>
- )}
- {activeTab === "schedule" && (
- <span>
- 일정 및 사양설명회 정보를 입력하세요
- {!tabValidation.schedule.isValid && (
- <span className="text-red-500 ml-2">• 필수 항목이 누락되었습니다</span>
- )}
- </span>
- )}
- {activeTab === "conditions" && (
- <span>
- 입찰 조건을 설정하세요
- {!tabValidation.conditions.isValid && (
- <span className="text-red-500 ml-2">• 필수 항목이 누락되었습니다</span>
- )}
- </span>
- )}
- {activeTab === "details" && (
- <span>
- 최소 하나의 품목을 입력하세요
- {!tabValidation.details.isValid && (
- <span className="text-red-500 ml-2">• 필수 항목이 누락되었습니다</span>
- )}
- </span>
- )}
- {activeTab === "manager" && "담당자 정보를 확인하고 입찰을 생성하세요"}
- </div>
-
- <div className="flex gap-3">
- <Button
- type="button"
- variant="outline"
- onClick={() => setShowCloseConfirmDialog(true)}
- disabled={isSubmitting}
- >
- 취소
- </Button>
-
- {/* 이전 버튼 (첫 번째 탭이 아닐 때) */}
- {!isFirstTab && (
- <Button
- type="button"
- variant="outline"
- onClick={goToPreviousTab}
- disabled={isSubmitting}
- className="flex items-center gap-2"
- >
- <ChevronLeft className="h-4 w-4" />
- 이전
- </Button>
- )}
-
- {/* 다음/생성 버튼 */}
- {isLastTab ? (
- // 마지막 탭: 입찰 생성 버튼 (type="button"으로 변경)
- <Button
- type="button"
- onClick={handleCreateBidding}
- disabled={isSubmitting}
- className="flex items-center gap-2"
- >
- {isSubmitting && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
- 입찰 생성
- </Button>
- ) : (
- // 이전 탭들: 다음 버튼
- <Button
- type="button"
- onClick={handleNextClick}
- disabled={isSubmitting}
- className="flex items-center gap-2"
- >
- 다음
- <ChevronRight className="h-4 w-4" />
- </Button>
- )}
- </div>
- </div>
+ <div className="space-y-2">
+ <Label>
+ 세금조건 <span className="text-red-500">*</span>
+ </Label>
+ <Select
+ value={biddingConditions.taxConditions}
+ onValueChange={(value) => setBiddingConditions(prev => ({
+ ...prev,
+ taxConditions: value
+ }))}
+ >
+ <SelectTrigger>
+ <SelectValue placeholder="세금조건 선택" />
+ </SelectTrigger>
+ <SelectContent>
+ {TAX_CONDITIONS.map((condition) => (
+ <SelectItem key={condition.code} value={condition.code}>
+ {condition.name}
+ </SelectItem>
+ ))}
+ </SelectContent>
+ </Select>
</div>
- </form>
- </Form>
- </DialogContent>
- </Dialog>
-
- {/* 닫기 확인 다이얼로그 */}
- <AlertDialog open={showCloseConfirmDialog} onOpenChange={setShowCloseConfirmDialog}>
- <AlertDialogContent>
- <AlertDialogHeader>
- <AlertDialogTitle>입찰 생성을 취소하시겠습니까?</AlertDialogTitle>
- <AlertDialogDescription>
- 현재 입력 중인 내용이 모두 삭제되며, 생성되지 않습니다.
- 정말로 취소하시겠습니까?
- </AlertDialogDescription>
- </AlertDialogHeader>
- <AlertDialogFooter>
- <AlertDialogCancel onClick={() => handleCloseConfirm(false)}>
- 아니오 (계속 입력)
- </AlertDialogCancel>
- <AlertDialogAction onClick={() => handleCloseConfirm(true)}>
- 예 (취소)
- </AlertDialogAction>
- </AlertDialogFooter>
- </AlertDialogContent>
- </AlertDialog>
-
- <AlertDialog open={showSuccessDialog} onOpenChange={setShowSuccessDialog}>
- <AlertDialogContent>
- <AlertDialogHeader>
- <AlertDialogTitle>입찰이 성공적으로 생성되었습니다</AlertDialogTitle>
- <AlertDialogDescription>
- 생성된 입찰의 상세페이지로 이동하시겠습니까?
- 아니면 현재 페이지에 남아있으시겠습니까?
- </AlertDialogDescription>
- </AlertDialogHeader>
- <AlertDialogFooter>
- <AlertDialogCancel onClick={handleStayOnPage}>
- 현재 페이지에 남기
- </AlertDialogCancel>
- <AlertDialogAction onClick={handleNavigateToDetail}>
- 상세페이지로 이동
- </AlertDialogAction>
- </AlertDialogFooter>
- </AlertDialogContent>
- </AlertDialog>
- </>
- )
+
+ <div className="space-y-2">
+ <Label>
+ 운송조건(인코텀즈) <span className="text-red-500">*</span>
+ </Label>
+ <Select
+ value={biddingConditions.incoterms}
+ onValueChange={(value) => setBiddingConditions(prev => ({
+ ...prev,
+ incoterms: value
+ }))}
+ >
+ <SelectTrigger>
+ <SelectValue placeholder="인코텀즈 선택" />
+ </SelectTrigger>
+ <SelectContent>
+ {incotermsOptions.length > 0 ? (
+ incotermsOptions.map((option) => (
+ <SelectItem key={option.code} value={option.code}>
+ {option.code} {option.description && `(${option.description})`}
+ </SelectItem>
+ ))
+ ) : (
+ <SelectItem value="loading" disabled>
+ 데이터를 불러오는 중...
+ </SelectItem>
+ )}
+ </SelectContent>
+ </Select>
+ </div>
+
+ <div className="space-y-2">
+ <Label>인코텀즈 옵션 (선택사항)</Label>
+ <Input
+ placeholder="예: 현지 배송 포함, 특정 주소 배송 등"
+ value={biddingConditions.incotermsOption}
+ onChange={(e) => setBiddingConditions(prev => ({
+ ...prev,
+ incotermsOption: e.target.value
+ }))}
+ />
+ <p className="text-xs text-muted-foreground">
+ 인코텀즈와 관련된 추가 조건이나 특이사항을 입력하세요
+ </p>
+ </div>
+
+ <div className="space-y-2">
+ <Label>
+ 계약 납품일 <span className="text-red-500">*</span>
+ </Label>
+ <Input
+ type="date"
+ value={biddingConditions.contractDeliveryDate}
+ onChange={(e) => setBiddingConditions(prev => ({
+ ...prev,
+ contractDeliveryDate: e.target.value
+ }))}
+ />
+ </div>
+
+ <div className="space-y-2">
+ <Label>선적지 (선택사항)</Label>
+ <Select
+ value={biddingConditions.shippingPort}
+ onValueChange={(value) => setBiddingConditions(prev => ({
+ ...prev,
+ shippingPort: value
+ }))}
+ >
+ <SelectTrigger>
+ <SelectValue placeholder="선적지 선택" />
+ </SelectTrigger>
+ <SelectContent>
+ {shippingPlaces.length > 0 ? (
+ shippingPlaces.map((place) => (
+ <SelectItem key={place.code} value={place.code}>
+ {place.code} {place.description && `(${place.description})`}
+ </SelectItem>
+ ))
+ ) : (
+ <SelectItem value="loading" disabled>
+ 데이터를 불러오는 중...
+ </SelectItem>
+ )}
+ </SelectContent>
+ </Select>
+ </div>
+
+ <div className="space-y-2">
+ <Label>하역지 (선택사항)</Label>
+ <Select
+ value={biddingConditions.destinationPort}
+ onValueChange={(value) => setBiddingConditions(prev => ({
+ ...prev,
+ destinationPort: value
+ }))}
+ >
+ <SelectTrigger>
+ <SelectValue placeholder="하역지 선택" />
+ </SelectTrigger>
+ <SelectContent>
+ {destinationPlaces.length > 0 ? (
+ destinationPlaces.map((place) => (
+ <SelectItem key={place.code} value={place.code}>
+ {place.code} {place.description && `(${place.description})`}
+ </SelectItem>
+ ))
+ ) : (
+ <SelectItem value="loading" disabled>
+ 데이터를 불러오는 중...
+ </SelectItem>
+ )}
+ </SelectContent>
+ </Select>
+ </div>
+ </div>
+
+ <div className="flex items-center space-x-2">
+ <Switch
+ id="price-adjustment"
+ checked={biddingConditions.isPriceAdjustmentApplicable}
+ onCheckedChange={(checked) => setBiddingConditions(prev => ({
+ ...prev,
+ isPriceAdjustmentApplicable: checked
+ }))}
+ />
+ <Label htmlFor="price-adjustment">
+ 연동제 적용 요건 문의
+ </Label>
+ </div>
+
+ <div className="space-y-2">
+ <Label>스페어파트 옵션</Label>
+ <Textarea
+ placeholder="스페어파트 관련 옵션을 입력하세요"
+ value={biddingConditions.sparePartOptions}
+ onChange={(e) => setBiddingConditions(prev => ({
+ ...prev,
+ sparePartOptions: e.target.value
+ }))}
+ rows={3}
+ />
+ </div>
+ </CardContent>
+ </Card>
+ </TabsContent>
+
+ <TabsContent value="details" className="mt-0 space-y-6">
+ <Card>
+ <CardHeader className="flex flex-row items-center justify-between">
+ <div>
+ <CardTitle>세부내역 관리</CardTitle>
+ <p className="text-sm text-muted-foreground mt-1">
+ 최소 하나의 아이템이 필요하며, 자재그룹코드는 필수입니다
+ </p>
+ <p className="text-xs text-amber-600 mt-1">
+ 수량/단위 또는 중량/중량단위를 선택해서 입력하세요
+ </p>
+ </div>
+ <Button
+ type="button"
+ variant="outline"
+ onClick={addPRItem}
+ className="flex items-center gap-2"
+ >
+ <Plus className="h-4 w-4" />
+ 아이템 추가
+ </Button>
+ </CardHeader>
+ <CardContent className="space-y-6">
+ <div className="flex items-center space-x-4 p-4 bg-muted rounded-lg">
+ <div className="text-sm font-medium">계산 기준:</div>
+ <div className="flex items-center space-x-2">
+ <input
+ type="radio"
+ id="quantity-mode"
+ name="quantityWeightMode"
+ checked={quantityWeightMode === 'quantity'}
+ onChange={() => handleQuantityWeightModeChange('quantity')}
+ className="h-4 w-4"
+ />
+ <label htmlFor="quantity-mode" className="text-sm">수량 기준</label>
+ </div>
+ <div className="flex items-center space-x-2">
+ <input
+ type="radio"
+ id="weight-mode"
+ name="quantityWeightMode"
+ checked={quantityWeightMode === 'weight'}
+ onChange={() => handleQuantityWeightModeChange('weight')}
+ className="h-4 w-4"
+ />
+ <label htmlFor="weight-mode" className="text-sm">중량 기준</label>
+ </div>
+ </div>
+ <div className="space-y-4">
+ {prItems.length > 0 ? (
+ renderPrItemsTable()
+ ) : (
+ <div className="text-center py-12 border-2 border-dashed border-gray-300 rounded-lg">
+ <FileText className="h-12 w-12 text-gray-400 mx-auto mb-4" />
+ <p className="text-gray-500 mb-2">아직 아이템이 없습니다</p>
+ <p className="text-sm text-gray-400 mb-4">
+ PR 아이템이나 수기 아이템을 추가하여 입찰 세부내역을 작성하세요
+ </p>
+ <Button
+ type="button"
+ variant="outline"
+ onClick={addPRItem}
+ className="flex items-center gap-2 mx-auto"
+ >
+ <Plus className="h-4 w-4" />
+ 첫 번째 아이템 추가
+ </Button>
+ </div>
+ )}
+ </div>
+ </CardContent>
+ </Card>
+ </TabsContent>
+
+ <TabsContent value="manager" className="mt-0 space-y-6">
+ <Card>
+ <CardHeader>
+ <CardTitle>담당자 정보</CardTitle>
+ </CardHeader>
+ <CardContent className="space-y-6">
+ <div className="grid grid-cols-2 gap-6">
+ <div className="space-y-2">
+ <Label>입찰담당자 <span className="text-red-500">*</span></Label>
+ <PurchaseGroupCodeSelector
+ onCodeSelect={(code) => {
+ form.setValue('managerName', code.DISPLAY_NAME || '')
+ }}
+ placeholder="입찰담당자 선택"
+ />
+ </div>
+ <div className="space-y-2">
+ <Label>조달담당자 <span className="text-red-500">*</span></Label>
+ <ProcurementManagerSelector
+ onManagerSelect={(manager) => {
+ form.setValue('managerEmail', manager.DISPLAY_NAME || '')
+ }}
+ placeholder="조달담당자 선택"
+ />
+ </div>
+ </div>
+ </CardContent>
+ </Card>
+ <Card>
+ <CardHeader>
+ <CardTitle>기타 설정</CardTitle>
+ </CardHeader>
+ <CardContent className="space-y-6">
+ <FormField
+ control={form.control}
+ name="isPublic"
+ render={({ field }) => (
+ <FormItem className="flex flex-row items-center justify-between rounded-lg border p-4">
+ <div className="space-y-0.5">
+ <FormLabel className="text-base">공개 입찰</FormLabel>
+ <FormDescription>
+ 공개 입찰 여부를 설정합니다
+ </FormDescription>
+ </div>
+ <FormControl>
+ <Switch checked={field.value} onCheckedChange={field.onChange} />
+ </FormControl>
+ </FormItem>
+ )}
+ />
+ <FormField
+ control={form.control}
+ name="remarks"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>비고</FormLabel>
+ <FormControl>
+ <Textarea placeholder="추가 메모나 특이사항을 입력하세요" rows={4} {...field} />
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+ </CardContent>
+ </Card>
+ </TabsContent>
+ </div>
+ </Tabs>
+ </div>
+
+ {/* 고정 버튼 영역 */}
+ <div className="flex-shrink-0 border-t bg-background p-6">
+ <div className="flex justify-between items-center">
+ <div className="text-sm text-muted-foreground">
+ {activeTab === 'basic' && (<span>기본 정보를 입력하세요.</span>)}
+ {activeTab === 'schedule' && (<span>일정 및 사양설명회 정보를 입력하세요.</span>)}
+ {activeTab === 'details' && (<span>세부내역을 관리하세요.</span>)}
+ {activeTab === 'manager' && (<span>담당자 정보를 확인하고 입찰을 생성하세요.</span>)}
+ {!tabValidation[activeTab].isValid && (
+ <span className="text-red-500 ml-2">• 필수 항목이 누락되었습니다</span>
+ )}
+ </div>
+ <div className="flex gap-3">
+ <Button
+ type="button"
+ variant="outline"
+ onClick={() => setShowCloseConfirmDialog(true)}
+ disabled={isSubmitting}
+ >
+ 취소
+ </Button>
+ {!isFirstTab && (
+ <Button
+ type="button"
+ variant="outline"
+ onClick={goToPreviousTab}
+ disabled={isSubmitting}
+ className="flex items-center gap-2"
+ >
+ <ChevronLeft className="h-4 w-4" />
+ 이전
+ </Button>
+ )}
+ {isLastTab ? (
+ <Button
+ type="button"
+ onClick={handleCreateBidding}
+ disabled={isSubmitting || !isCurrentTabValid()}
+ className="flex items-center gap-2"
+ >
+ {isSubmitting && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
+ 입찰 생성
+ </Button>
+ ) : (
+ <Button
+ type="button"
+ onClick={handleNextClick}
+ disabled={isSubmitting || !isCurrentTabValid()}
+ className="flex items-center gap-2"
+ >
+ 다음
+ <ChevronRight className="h-4 w-4" />
+ </Button>
+ )}
+ </div>
+ </div>
+ </div>
+ </form>
+ </Form>
+ </DialogContent>
+ </Dialog>
+ {/* 닫기 확인 다이얼로그 */}
+ <AlertDialog open={showCloseConfirmDialog} onOpenChange={setShowCloseConfirmDialog}>
+ <AlertDialogContent>
+ <AlertDialogHeader>
+ <AlertDialogTitle>입찰 생성을 취소하시겠습니까?</AlertDialogTitle>
+ <AlertDialogDescription>
+ 현재 입력 중인 내용이 모두 삭제되며, 생성되지 않습니다. 정말로 취소하시겠습니까?
+ </AlertDialogDescription>
+ </AlertDialogHeader>
+ <AlertDialogFooter>
+ <AlertDialogCancel onClick={() => handleCloseConfirm(false)}>
+ 아니오 (계속 입력)
+ </AlertDialogCancel>
+ <AlertDialogAction onClick={() => handleCloseConfirm(true)}>
+ 예 (취소)
+ </AlertDialogAction>
+ </AlertDialogFooter>
+ </AlertDialogContent>
+ </AlertDialog>
+ {/* 성공 다이얼로그 */}
+ <AlertDialog open={showSuccessDialog} onOpenChange={setShowSuccessDialog}>
+ <AlertDialogContent>
+ <AlertDialogHeader>
+ <AlertDialogTitle>입찰이 성공적으로 생성되었습니다</AlertDialogTitle>
+ <AlertDialogDescription>
+ 생성된 입찰의 상세페이지로 이동하시겠습니까? 아니면 현재 페이지에 남아있으시겠습니까?
+ </AlertDialogDescription>
+ </AlertDialogHeader>
+ <AlertDialogFooter>
+ <AlertDialogCancel onClick={handleStayOnPage}>현재 페이지에 남기</AlertDialogCancel>
+ <AlertDialogAction onClick={handleNavigateToDetail}>상세페이지로 이동</AlertDialogAction>
+ </AlertDialogFooter>
+ </AlertDialogContent>
+ </AlertDialog>
+ </>
+ )
} \ No newline at end of file
diff --git a/lib/bidding/pre-quote/service.ts b/lib/bidding/pre-quote/service.ts
index 0f284297..ea92f294 100644
--- a/lib/bidding/pre-quote/service.ts
+++ b/lib/bidding/pre-quote/service.ts
@@ -1,1528 +1,1619 @@
-'use server'
-
-import db from '@/db/db'
-import { biddingCompanies, companyConditionResponses, biddings, prItemsForBidding, biddingDocuments, companyPrItemBids, priceAdjustmentForms } from '@/db/schema/bidding'
-import { basicContractTemplates } from '@/db/schema'
-import { vendors } from '@/db/schema/vendors'
-import { users } from '@/db/schema'
-import { sendEmail } from '@/lib/mail/sendEmail'
-import { eq, inArray, and, ilike, sql } from 'drizzle-orm'
-import { mkdir, writeFile } from 'fs/promises'
-import path from 'path'
-import { revalidateTag, revalidatePath } from 'next/cache'
-import { basicContract } from '@/db/schema/basicContractDocumnet'
-import { saveFile } from '@/lib/file-stroage'
-
-// userId를 user.name으로 변환하는 유틸리티 함수
-async function getUserNameById(userId: string): Promise<string> {
- try {
- const user = await db
- .select({ name: users.name })
- .from(users)
- .where(eq(users.id, parseInt(userId)))
- .limit(1)
-
- return user[0]?.name || userId // user.name이 없으면 userId를 그대로 반환
- } catch (error) {
- console.error('Failed to get user name:', error)
- return userId // 에러 시 userId를 그대로 반환
- }
-}
-
-interface CreateBiddingCompanyInput {
- biddingId: number
- companyId: number
- contactPerson?: string
- contactEmail?: string
- contactPhone?: string
- notes?: string
-}
-
-interface UpdateBiddingCompanyInput {
- contactPerson?: string
- contactEmail?: string
- contactPhone?: string
- preQuoteAmount?: number
- notes?: string
- invitationStatus?: 'pending' | 'accepted' | 'declined'
- isPreQuoteSelected?: boolean
- isAttendingMeeting?: boolean
-}
-
-interface PrItemQuotation {
- prItemId: number
- bidUnitPrice: number
- bidAmount: number
- proposedDeliveryDate?: string
- technicalSpecification?: string
-}
-
-
-
-// 사전견적용 업체 추가 - biddingCompanies와 company_condition_responses 레코드 생성
-export async function createBiddingCompany(input: CreateBiddingCompanyInput) {
- try {
- const result = await db.transaction(async (tx) => {
- // 0. 중복 체크 - 이미 해당 입찰에 참여중인 업체인지 확인
- const existingCompany = await tx
- .select()
- .from(biddingCompanies)
- .where(sql`${biddingCompanies.biddingId} = ${input.biddingId} AND ${biddingCompanies.companyId} = ${input.companyId}`)
-
- if (existingCompany.length > 0) {
- throw new Error('이미 등록된 업체입니다')
- }
- // 1. biddingCompanies 레코드 생성
- const biddingCompanyResult = await tx.insert(biddingCompanies).values({
- biddingId: input.biddingId,
- companyId: input.companyId,
- invitationStatus: 'pending', // 초기 상태: 입찰생성
- invitedAt: new Date(),
- contactPerson: input.contactPerson,
- contactEmail: input.contactEmail,
- contactPhone: input.contactPhone,
- notes: input.notes,
- }).returning({ id: biddingCompanies.id })
-
- if (biddingCompanyResult.length === 0) {
- throw new Error('업체 추가에 실패했습니다.')
- }
-
- const biddingCompanyId = biddingCompanyResult[0].id
-
- // 2. company_condition_responses 레코드 생성 (기본값으로)
- await tx.insert(companyConditionResponses).values({
- biddingCompanyId: biddingCompanyId,
- // 나머지 필드들은 null로 시작 (벤더가 나중에 응답)
- })
-
- return biddingCompanyId
- })
-
- return {
- success: true,
- message: '업체가 성공적으로 추가되었습니다.',
- data: { id: result }
- }
- } catch (error) {
- console.error('Failed to create bidding company:', error)
- return {
- success: false,
- error: error instanceof Error ? error.message : '업체 추가에 실패했습니다.'
- }
- }
-}
-
-// 사전견적용 업체 정보 업데이트
-export async function updateBiddingCompany(id: number, input: UpdateBiddingCompanyInput) {
- try {
- const updateData: any = {
- updatedAt: new Date()
- }
-
- if (input.contactPerson !== undefined) updateData.contactPerson = input.contactPerson
- if (input.contactEmail !== undefined) updateData.contactEmail = input.contactEmail
- if (input.contactPhone !== undefined) updateData.contactPhone = input.contactPhone
- if (input.preQuoteAmount !== undefined) updateData.preQuoteAmount = input.preQuoteAmount
- if (input.notes !== undefined) updateData.notes = input.notes
- if (input.invitationStatus !== undefined) {
- updateData.invitationStatus = input.invitationStatus
- if (input.invitationStatus !== 'pending') {
- updateData.respondedAt = new Date()
- }
- }
- if (input.isPreQuoteSelected !== undefined) updateData.isPreQuoteSelected = input.isPreQuoteSelected
- if (input.isAttendingMeeting !== undefined) updateData.isAttendingMeeting = input.isAttendingMeeting
-
- await db.update(biddingCompanies)
- .set(updateData)
- .where(eq(biddingCompanies.id, id))
-
- return {
- success: true,
- message: '업체 정보가 성공적으로 업데이트되었습니다.',
- }
- } catch (error) {
- console.error('Failed to update bidding company:', error)
- return {
- success: false,
- error: error instanceof Error ? error.message : '업체 정보 업데이트에 실패했습니다.'
- }
- }
-}
-
-// 본입찰 등록 상태 업데이트 (복수 업체 선택 가능)
-export async function updatePreQuoteSelection(companyIds: number[], isSelected: boolean) {
- try {
- // 업체들의 입찰 ID 조회 (캐시 무효화를 위해)
- const companies = await db
- .select({ biddingId: biddingCompanies.biddingId })
- .from(biddingCompanies)
- .where(inArray(biddingCompanies.id, companyIds))
- .limit(1)
-
- await db.update(biddingCompanies)
- .set({
- isPreQuoteSelected: isSelected,
- invitationStatus: 'pending', // 초기 상태: 입찰생성
- updatedAt: new Date()
- })
- .where(inArray(biddingCompanies.id, companyIds))
-
- // 캐시 무효화
- if (companies.length > 0) {
- const biddingId = companies[0].biddingId
- revalidateTag(`bidding-${biddingId}`)
- revalidateTag('bidding-detail')
- revalidateTag('quotation-vendors')
- revalidateTag('quotation-details')
- revalidatePath(`/evcp/bid/${biddingId}`)
- }
-
- const message = isSelected
- ? `${companyIds.length}개 업체가 본입찰 대상으로 선정되었습니다.`
- : `${companyIds.length}개 업체의 본입찰 선정이 취소되었습니다.`
-
- return {
- success: true,
- message
- }
- } catch (error) {
- console.error('Failed to update pre-quote selection:', error)
- return {
- success: false,
- error: error instanceof Error ? error.message : '본입찰 선정 상태 업데이트에 실패했습니다.'
- }
- }
-}
-
-// 사전견적용 업체 삭제
-export async function deleteBiddingCompany(id: number) {
- try {
- // 1. 해당 업체의 초대 상태 확인
- const company = await db
- .select({ invitationStatus: biddingCompanies.invitationStatus })
- .from(biddingCompanies)
- .where(eq(biddingCompanies.id, id))
- .then(rows => rows[0])
-
- if (!company) {
- return {
- success: false,
- error: '해당 업체를 찾을 수 없습니다.'
- }
- }
-
- // 이미 초대가 발송된 경우(수락, 거절, 요청됨 등) 삭제 불가
- if (company.invitationStatus !== 'pending') {
- return {
- success: false,
- error: '이미 초대를 보낸 업체는 삭제할 수 없습니다.'
- }
- }
-
- await db.transaction(async (tx) => {
- // 2. 먼저 관련된 조건 응답들 삭제
- await tx.delete(companyConditionResponses)
- .where(eq(companyConditionResponses.biddingCompanyId, id))
-
- // 3. biddingCompanies 레코드 삭제
- await tx.delete(biddingCompanies)
- .where(eq(biddingCompanies.id, id))
- })
-
- return {
- success: true,
- message: '업체가 성공적으로 삭제되었습니다.'
- }
- } catch (error) {
- console.error('Failed to delete bidding company:', error)
- return {
- success: false,
- error: error instanceof Error ? error.message : '업체 삭제에 실패했습니다.'
- }
- }
-}
-
-// 특정 입찰의 참여 업체 목록 조회 (company_condition_responses와 vendors 조인)
-export async function getBiddingCompanies(biddingId: number) {
- try {
- const companies = await db
- .select({
- // bidding_companies 필드들
- id: biddingCompanies.id,
- biddingId: biddingCompanies.biddingId,
- companyId: biddingCompanies.companyId,
- invitationStatus: biddingCompanies.invitationStatus,
- invitedAt: biddingCompanies.invitedAt,
- respondedAt: biddingCompanies.respondedAt,
- preQuoteAmount: biddingCompanies.preQuoteAmount,
- preQuoteSubmittedAt: biddingCompanies.preQuoteSubmittedAt,
- preQuoteDeadline: biddingCompanies.preQuoteDeadline,
- isPreQuoteSelected: biddingCompanies.isPreQuoteSelected,
- isPreQuoteParticipated: biddingCompanies.isPreQuoteParticipated,
- isAttendingMeeting: biddingCompanies.isAttendingMeeting,
- notes: biddingCompanies.notes,
- contactPerson: biddingCompanies.contactPerson,
- contactEmail: biddingCompanies.contactEmail,
- contactPhone: biddingCompanies.contactPhone,
- createdAt: biddingCompanies.createdAt,
- updatedAt: biddingCompanies.updatedAt,
-
- // vendors 테이블에서 업체 정보
- companyName: vendors.vendorName,
- companyCode: vendors.vendorCode,
-
- // company_condition_responses 필드들
- paymentTermsResponse: companyConditionResponses.paymentTermsResponse,
- taxConditionsResponse: companyConditionResponses.taxConditionsResponse,
- proposedContractDeliveryDate: companyConditionResponses.proposedContractDeliveryDate,
- priceAdjustmentResponse: companyConditionResponses.priceAdjustmentResponse,
- isInitialResponse: companyConditionResponses.isInitialResponse,
- incotermsResponse: companyConditionResponses.incotermsResponse,
- proposedShippingPort: companyConditionResponses.proposedShippingPort,
- proposedDestinationPort: companyConditionResponses.proposedDestinationPort,
- sparePartResponse: companyConditionResponses.sparePartResponse,
- additionalProposals: companyConditionResponses.additionalProposals,
- })
- .from(biddingCompanies)
- .leftJoin(
- vendors,
- eq(biddingCompanies.companyId, vendors.id)
- )
- .leftJoin(
- companyConditionResponses,
- eq(biddingCompanies.id, companyConditionResponses.biddingCompanyId)
- )
- .where(eq(biddingCompanies.biddingId, biddingId))
-
- return {
- success: true,
- data: companies
- }
- } catch (error) {
- console.error('Failed to get bidding companies:', error)
- return {
- success: false,
- error: error instanceof Error ? error.message : '업체 목록 조회에 실패했습니다.'
- }
- }
-}
-
-// 선택된 업체들에게 사전견적 초대 발송
-export async function sendPreQuoteInvitations(companyIds: number[], preQuoteDeadline?: Date | string) {
- try {
- if (companyIds.length === 0) {
- return {
- success: false,
- error: '선택된 업체가 없습니다.'
- }
- }
-
- // 선택된 업체들의 정보와 입찰 정보 조회
- const companiesInfo = await db
- .select({
- biddingCompanyId: biddingCompanies.id,
- companyId: biddingCompanies.companyId,
- biddingId: biddingCompanies.biddingId,
- companyName: vendors.vendorName,
- companyEmail: vendors.email,
- // 입찰 정보
- biddingNumber: biddings.biddingNumber,
- revision: biddings.revision,
- projectName: biddings.projectName,
- biddingTitle: biddings.title,
- itemName: biddings.itemName,
- preQuoteDate: biddings.preQuoteDate,
- budget: biddings.budget,
- currency: biddings.currency,
- managerName: biddings.managerName,
- managerEmail: biddings.managerEmail,
- managerPhone: biddings.managerPhone,
- })
- .from(biddingCompanies)
- .leftJoin(vendors, eq(biddingCompanies.companyId, vendors.id))
- .leftJoin(biddings, eq(biddingCompanies.biddingId, biddings.id))
- .where(inArray(biddingCompanies.id, companyIds))
-
- if (companiesInfo.length === 0) {
- return {
- success: false,
- error: '업체 정보를 찾을 수 없습니다.'
- }
- }
-
- await db.transaction(async (tx) => {
- // 선택된 업체들의 상태를 '사전견적요청(초대발송)'으로 변경
- for (const id of companyIds) {
- await tx.update(biddingCompanies)
- .set({
- invitationStatus: 'sent', // 사전견적 초대 발송 상태
- invitedAt: new Date(),
- preQuoteDeadline: preQuoteDeadline ? new Date(preQuoteDeadline) : null,
- updatedAt: new Date()
- })
- .where(eq(biddingCompanies.id, id))
- }
- })
-
- // 각 업체별로 이메일 발송
- for (const company of companiesInfo) {
- if (company.companyEmail) {
- try {
- await sendEmail({
- to: company.companyEmail,
- template: 'pre-quote-invitation',
- context: {
- companyName: company.companyName,
- biddingNumber: company.biddingNumber,
- revision: company.revision,
- projectName: company.projectName,
- biddingTitle: company.biddingTitle,
- itemName: company.itemName,
- preQuoteDate: company.preQuoteDate ? new Date(company.preQuoteDate).toLocaleDateString() : null,
- budget: company.budget ? company.budget.toLocaleString() : null,
- currency: company.currency,
- managerName: company.managerName,
- managerEmail: company.managerEmail,
- managerPhone: company.managerPhone,
- loginUrl: `${process.env.NEXT_PUBLIC_APP_URL}/partners/bid/${company.biddingId}/pre-quote`,
- currentYear: new Date().getFullYear(),
- language: 'ko'
- }
- })
- } catch (emailError) {
- console.error(`Failed to send email to ${company.companyEmail}:`, emailError)
- // 이메일 발송 실패해도 전체 프로세스는 계속 진행
- }
- }
- }
- // 3. 입찰 상태를 사전견적 요청으로 변경 (bidding_generated 상태에서만)
- for (const company of companiesInfo) {
- await db.transaction(async (tx) => {
- await tx
- .update(biddings)
- .set({
- status: 'request_for_quotation',
- updatedAt: new Date()
- })
- .where(and(
- eq(biddings.id, company.biddingId),
- eq(biddings.status, 'bidding_generated')
- ))
- })
- }
- return {
- success: true,
- message: `${companyIds.length}개 업체에 사전견적 초대를 발송했습니다.`
- }
- } catch (error) {
- console.error('Failed to send pre-quote invitations:', error)
- return {
- success: false,
- error: error instanceof Error ? error.message : '초대 발송에 실패했습니다.'
- }
- }
-}
-
-// Partners에서 특정 업체의 입찰 정보 조회 (사전견적 단계)
-export async function getBiddingCompaniesForPartners(biddingId: number, companyId: number) {
- try {
- // 1. 먼저 입찰 기본 정보를 가져옴
- const biddingResult = await db
- .select({
- id: biddings.id,
- biddingNumber: biddings.biddingNumber,
- revision: biddings.revision,
- projectName: biddings.projectName,
- itemName: biddings.itemName,
- title: biddings.title,
- description: biddings.description,
- content: biddings.content,
- contractType: biddings.contractType,
- biddingType: biddings.biddingType,
- awardCount: biddings.awardCount,
- contractStartDate: biddings.contractStartDate,
- contractEndDate: biddings.contractEndDate,
- preQuoteDate: biddings.preQuoteDate,
- biddingRegistrationDate: biddings.biddingRegistrationDate,
- submissionStartDate: biddings.submissionStartDate,
- submissionEndDate: biddings.submissionEndDate,
- evaluationDate: biddings.evaluationDate,
- currency: biddings.currency,
- budget: biddings.budget,
- targetPrice: biddings.targetPrice,
- status: biddings.status,
- managerName: biddings.managerName,
- managerEmail: biddings.managerEmail,
- managerPhone: biddings.managerPhone,
- })
- .from(biddings)
- .where(eq(biddings.id, biddingId))
- .limit(1)
-
- if (biddingResult.length === 0) {
- return null
- }
-
- const biddingData = biddingResult[0]
-
- // 2. 해당 업체의 biddingCompanies 정보 조회
- const companyResult = await db
- .select({
- biddingCompanyId: biddingCompanies.id,
- biddingId: biddingCompanies.biddingId,
- invitationStatus: biddingCompanies.invitationStatus,
- preQuoteAmount: biddingCompanies.preQuoteAmount,
- preQuoteSubmittedAt: biddingCompanies.preQuoteSubmittedAt,
- preQuoteDeadline: biddingCompanies.preQuoteDeadline,
- isPreQuoteSelected: biddingCompanies.isPreQuoteSelected,
- isPreQuoteParticipated: biddingCompanies.isPreQuoteParticipated,
- isAttendingMeeting: biddingCompanies.isAttendingMeeting,
- // company_condition_responses 정보
- paymentTermsResponse: companyConditionResponses.paymentTermsResponse,
- taxConditionsResponse: companyConditionResponses.taxConditionsResponse,
- incotermsResponse: companyConditionResponses.incotermsResponse,
- proposedContractDeliveryDate: companyConditionResponses.proposedContractDeliveryDate,
- proposedShippingPort: companyConditionResponses.proposedShippingPort,
- proposedDestinationPort: companyConditionResponses.proposedDestinationPort,
- priceAdjustmentResponse: companyConditionResponses.priceAdjustmentResponse,
- sparePartResponse: companyConditionResponses.sparePartResponse,
- isInitialResponse: companyConditionResponses.isInitialResponse,
- additionalProposals: companyConditionResponses.additionalProposals,
- })
- .from(biddingCompanies)
- .leftJoin(
- companyConditionResponses,
- eq(biddingCompanies.id, companyConditionResponses.biddingCompanyId)
- )
- .where(
- and(
- eq(biddingCompanies.biddingId, biddingId),
- eq(biddingCompanies.companyId, companyId)
- )
- )
- .limit(1)
-
- // 3. 결과 조합
- if (companyResult.length === 0) {
- // 아직 초대되지 않은 상태
- return {
- ...biddingData,
- biddingCompanyId: null,
- biddingId: biddingData.id,
- invitationStatus: null,
- preQuoteAmount: null,
- preQuoteSubmittedAt: null,
- preQuoteDeadline: null,
- isPreQuoteSelected: false,
- isPreQuoteParticipated: null,
- isAttendingMeeting: null,
- paymentTermsResponse: null,
- taxConditionsResponse: null,
- incotermsResponse: null,
- proposedContractDeliveryDate: null,
- proposedShippingPort: null,
- proposedDestinationPort: null,
- priceAdjustmentResponse: null,
- sparePartResponse: null,
- isInitialResponse: null,
- additionalProposals: null,
- }
- }
-
- const companyData = companyResult[0]
-
- return {
- ...biddingData,
- ...companyData,
- biddingId: biddingData.id, // bidding ID 보장
- }
- } catch (error) {
- console.error('Failed to get bidding companies for partners:', error)
- throw error
- }
-}
-
-// Partners에서 사전견적 응답 제출
-export async function submitPreQuoteResponse(
- biddingCompanyId: number,
- responseData: {
- preQuoteAmount?: number // 품목별 계산에서 자동으로 계산되므로 optional
- prItemQuotations?: PrItemQuotation[] // 품목별 견적 정보 추가
- paymentTermsResponse?: string
- taxConditionsResponse?: string
- incotermsResponse?: string
- proposedContractDeliveryDate?: string
- proposedShippingPort?: string
- proposedDestinationPort?: string
- priceAdjustmentResponse?: boolean
- isInitialResponse?: boolean
- sparePartResponse?: string
- additionalProposals?: string
- priceAdjustmentForm?: any
- },
- userId: string
-) {
- try {
- let finalAmount = responseData.preQuoteAmount || 0
-
- await db.transaction(async (tx) => {
- // 1. 품목별 견적 정보 최종 저장 (사전견적 제출)
- if (responseData.prItemQuotations && responseData.prItemQuotations.length > 0) {
- // 기존 사전견적 품목 삭제 후 새로 생성
- await tx.delete(companyPrItemBids)
- .where(
- and(
- eq(companyPrItemBids.biddingCompanyId, biddingCompanyId),
- eq(companyPrItemBids.isPreQuote, true)
- )
- )
-
- // 품목별 견적 최종 저장
- for (const item of responseData.prItemQuotations) {
- await tx.insert(companyPrItemBids)
- .values({
- biddingCompanyId,
- prItemId: item.prItemId,
- bidUnitPrice: item.bidUnitPrice.toString(),
- bidAmount: item.bidAmount.toString(),
- proposedDeliveryDate: item.proposedDeliveryDate || null,
- technicalSpecification: item.technicalSpecification || null,
- currency: 'KRW',
- isPreQuote: true,
- submittedAt: new Date(),
- createdAt: new Date(),
- updatedAt: new Date()
- })
- }
-
- // 총 금액 다시 계산
- finalAmount = responseData.prItemQuotations.reduce((sum, item) => sum + item.bidAmount, 0)
- }
-
- // 2. biddingCompanies 업데이트 (사전견적 금액, 제출 시간, 상태 변경)
- await tx.update(biddingCompanies)
- .set({
- preQuoteAmount: finalAmount.toString(),
- preQuoteSubmittedAt: new Date(),
- invitationStatus: 'submitted', // 사전견적 제출 완료 상태로 변경
- updatedAt: new Date()
- })
- .where(eq(biddingCompanies.id, biddingCompanyId))
-
- // 3. company_condition_responses 업데이트
- const finalConditionResult = await tx.update(companyConditionResponses)
- .set({
- paymentTermsResponse: responseData.paymentTermsResponse,
- taxConditionsResponse: responseData.taxConditionsResponse,
- incotermsResponse: responseData.incotermsResponse,
- proposedContractDeliveryDate: responseData.proposedContractDeliveryDate,
- proposedShippingPort: responseData.proposedShippingPort,
- proposedDestinationPort: responseData.proposedDestinationPort,
- priceAdjustmentResponse: responseData.priceAdjustmentResponse,
- isInitialResponse: responseData.isInitialResponse,
- sparePartResponse: responseData.sparePartResponse,
- additionalProposals: responseData.additionalProposals,
- updatedAt: new Date()
- })
- .where(eq(companyConditionResponses.biddingCompanyId, biddingCompanyId))
- .returning()
-
- // 4. 연동제 정보 저장 (연동제 적용이 true이고 연동제 정보가 있는 경우)
- if (responseData.priceAdjustmentResponse && responseData.priceAdjustmentForm && finalConditionResult.length > 0) {
- const companyConditionResponseId = finalConditionResult[0].id
-
- const priceAdjustmentData = {
- companyConditionResponsesId: companyConditionResponseId,
- itemName: responseData.priceAdjustmentForm.itemName,
- adjustmentReflectionPoint: responseData.priceAdjustmentForm.adjustmentReflectionPoint,
- majorApplicableRawMaterial: responseData.priceAdjustmentForm.majorApplicableRawMaterial,
- adjustmentFormula: responseData.priceAdjustmentForm.adjustmentFormula,
- rawMaterialPriceIndex: responseData.priceAdjustmentForm.rawMaterialPriceIndex,
- referenceDate: responseData.priceAdjustmentForm.referenceDate as string || null,
- comparisonDate: responseData.priceAdjustmentForm.comparisonDate as string || null,
- adjustmentRatio: responseData.priceAdjustmentForm.adjustmentRatio || null,
- notes: responseData.priceAdjustmentForm.notes,
- adjustmentConditions: responseData.priceAdjustmentForm.adjustmentConditions,
- majorNonApplicableRawMaterial: responseData.priceAdjustmentForm.majorNonApplicableRawMaterial,
- adjustmentPeriod: responseData.priceAdjustmentForm.adjustmentPeriod,
- contractorWriter: responseData.priceAdjustmentForm.contractorWriter,
- adjustmentDate: responseData.priceAdjustmentForm.adjustmentDate as string || null,
- nonApplicableReason: responseData.priceAdjustmentForm.nonApplicableReason,
- } as any
-
- // 기존 연동제 정보가 있는지 확인
- const existingPriceAdjustment = await tx
- .select()
- .from(priceAdjustmentForms)
- .where(eq(priceAdjustmentForms.companyConditionResponsesId, companyConditionResponseId))
- .limit(1)
-
- if (existingPriceAdjustment.length > 0) {
- // 업데이트
- await tx
- .update(priceAdjustmentForms)
- .set(priceAdjustmentData)
- .where(eq(priceAdjustmentForms.companyConditionResponsesId, companyConditionResponseId))
- } else {
- // 새로 생성
- await tx.insert(priceAdjustmentForms).values(priceAdjustmentData)
- }
- }
-
- // 5. 입찰 상태를 사전견적 접수로 변경 (request_for_quotation 상태에서만)
- // 또한 사전견적 접수일 업데이트
- const biddingCompany = await tx
- .select({ biddingId: biddingCompanies.biddingId })
- .from(biddingCompanies)
- .where(eq(biddingCompanies.id, biddingCompanyId))
- .limit(1)
-
- if (biddingCompany.length > 0) {
- await tx
- .update(biddings)
- .set({
- status: 'received_quotation',
- preQuoteDate: new Date().toISOString().split('T')[0], // 사전견적 접수일 업데이트
- updatedAt: new Date()
- })
- .where(and(
- eq(biddings.id, biddingCompany[0].biddingId),
- eq(biddings.status, 'request_for_quotation')
- ))
- }
- })
-
- return {
- success: true,
- message: '사전견적이 성공적으로 제출되었습니다.'
- }
- } catch (error) {
- console.error('Failed to submit pre-quote response:', error)
- return {
- success: false,
- error: error instanceof Error ? error.message : '사전견적 제출에 실패했습니다.'
- }
- }
-}
-
-// Partners에서 사전견적 참여 의사 결정 (수락/거절)
-export async function respondToPreQuoteInvitation(
- biddingCompanyId: number,
- response: 'accepted' | 'declined'
-) {
- try {
- await db.update(biddingCompanies)
- .set({
- invitationStatus: response, // accepted 또는 declined
- respondedAt: new Date(),
- updatedAt: new Date()
- })
- .where(eq(biddingCompanies.id, biddingCompanyId))
-
- const message = response === 'accepted' ?
- '사전견적 참여를 수락했습니다.' :
- '사전견적 참여를 거절했습니다.'
-
- return {
- success: true,
- message
- }
- } catch (error) {
- console.error('Failed to respond to pre-quote invitation:', error)
- return {
- success: false,
- error: error instanceof Error ? error.message : '응답 처리에 실패했습니다.'
- }
- }
-}
-
-// 벤더에서 사전견적 참여 여부 결정 (isPreQuoteParticipated 사용)
-export async function setPreQuoteParticipation(
- biddingCompanyId: number,
- isParticipating: boolean
-) {
- try {
- await db.update(biddingCompanies)
- .set({
- isPreQuoteParticipated: isParticipating,
- respondedAt: new Date(),
- updatedAt: new Date()
- })
- .where(eq(biddingCompanies.id, biddingCompanyId))
-
- const message = isParticipating ?
- '사전견적 참여를 확정했습니다. 이제 견적서를 작성하실 수 있습니다.' :
- '사전견적 참여를 거절했습니다.'
-
- return {
- success: true,
- message
- }
- } catch (error) {
- console.error('Failed to set pre-quote participation:', error)
- return {
- success: false,
- error: error instanceof Error ? error.message : '참여 의사 처리에 실패했습니다.'
- }
- }
-}
-
-// PR 아이템 조회 (입찰에 포함된 품목들)
-export async function getPrItemsForBidding(biddingId: number) {
- try {
- const prItems = await db
- .select({
- id: prItemsForBidding.id,
- itemNumber: prItemsForBidding.itemNumber,
- prNumber: prItemsForBidding.prNumber,
- itemInfo: prItemsForBidding.itemInfo,
- materialDescription: prItemsForBidding.materialDescription,
- quantity: prItemsForBidding.quantity,
- quantityUnit: prItemsForBidding.quantityUnit,
- totalWeight: prItemsForBidding.totalWeight,
- weightUnit: prItemsForBidding.weightUnit,
- currency: prItemsForBidding.currency,
- requestedDeliveryDate: prItemsForBidding.requestedDeliveryDate,
- hasSpecDocument: prItemsForBidding.hasSpecDocument
- })
- .from(prItemsForBidding)
- .where(eq(prItemsForBidding.biddingId, biddingId))
-
- return prItems
- } catch (error) {
- console.error('Failed to get PR items for bidding:', error)
- return []
- }
-}
-
-// SPEC 문서 조회 (PR 아이템에 연결된 문서들)
-export async function getSpecDocumentsForPrItem(prItemId: number) {
- try {
-
- const specDocs = await db
- .select({
- id: biddingDocuments.id,
- fileName: biddingDocuments.fileName,
- originalFileName: biddingDocuments.originalFileName,
- fileSize: biddingDocuments.fileSize,
- filePath: biddingDocuments.filePath,
- title: biddingDocuments.title,
- description: biddingDocuments.description,
- uploadedAt: biddingDocuments.uploadedAt
- })
- .from(biddingDocuments)
- .where(
- and(
- eq(biddingDocuments.prItemId, prItemId),
- eq(biddingDocuments.documentType, 'spec_document')
- )
- )
-
- return specDocs
- } catch (error) {
- console.error('Failed to get spec documents for PR item:', error)
- return []
- }
-}
-
-// 사전견적 임시저장
-export async function savePreQuoteDraft(
- biddingCompanyId: number,
- responseData: {
- prItemQuotations?: PrItemQuotation[]
- paymentTermsResponse?: string
- taxConditionsResponse?: string
- incotermsResponse?: string
- proposedContractDeliveryDate?: string
- proposedShippingPort?: string
- proposedDestinationPort?: string
- priceAdjustmentResponse?: boolean
- isInitialResponse?: boolean
- sparePartResponse?: string
- additionalProposals?: string
- priceAdjustmentForm?: any
- },
- userId: string
-) {
- try {
- let totalAmount = 0
-
- await db.transaction(async (tx) => {
- // 품목별 견적 정보 저장
- if (responseData.prItemQuotations && responseData.prItemQuotations.length > 0) {
- // 기존 사전견적 품목 삭제 (임시저장 시 덮어쓰기)
- await tx.delete(companyPrItemBids)
- .where(
- and(
- eq(companyPrItemBids.biddingCompanyId, biddingCompanyId),
- eq(companyPrItemBids.isPreQuote, true)
- )
- )
-
- // 새로운 품목별 견적 저장
- for (const item of responseData.prItemQuotations) {
- await tx.insert(companyPrItemBids)
- .values({
- biddingCompanyId,
- prItemId: item.prItemId,
- bidUnitPrice: item.bidUnitPrice.toString(),
- bidAmount: item.bidAmount.toString(),
- proposedDeliveryDate: item.proposedDeliveryDate || null,
- technicalSpecification: item.technicalSpecification || null,
- currency: 'KRW',
- isPreQuote: true, // 사전견적 표시
- submittedAt: new Date(),
- createdAt: new Date(),
- updatedAt: new Date()
- })
- }
-
- // 총 금액 계산
- totalAmount = responseData.prItemQuotations.reduce((sum, item) => sum + item.bidAmount, 0)
-
- // biddingCompanies에 총 금액 임시 저장 (status는 변경하지 않음)
- await tx.update(biddingCompanies)
- .set({
- preQuoteAmount: totalAmount.toString(),
- updatedAt: new Date()
- })
- .where(eq(biddingCompanies.id, biddingCompanyId))
- }
-
- // company_condition_responses 업데이트 (임시저장)
- const conditionResult = await tx.update(companyConditionResponses)
- .set({
- paymentTermsResponse: responseData.paymentTermsResponse || null,
- taxConditionsResponse: responseData.taxConditionsResponse || null,
- incotermsResponse: responseData.incotermsResponse || null,
- proposedContractDeliveryDate: responseData.proposedContractDeliveryDate || null,
- proposedShippingPort: responseData.proposedShippingPort || null,
- proposedDestinationPort: responseData.proposedDestinationPort || null,
- priceAdjustmentResponse: responseData.priceAdjustmentResponse || null,
- isInitialResponse: responseData.isInitialResponse || null,
- sparePartResponse: responseData.sparePartResponse || null,
- additionalProposals: responseData.additionalProposals || null,
- updatedAt: new Date()
- })
- .where(eq(companyConditionResponses.biddingCompanyId, biddingCompanyId))
- .returning()
-
- // 연동제 정보 저장 (연동제 적용이 true이고 연동제 정보가 있는 경우)
- if (responseData.priceAdjustmentResponse && responseData.priceAdjustmentForm && conditionResult.length > 0) {
- const companyConditionResponseId = conditionResult[0].id
-
- const priceAdjustmentData = {
- companyConditionResponsesId: companyConditionResponseId,
- itemName: responseData.priceAdjustmentForm.itemName,
- adjustmentReflectionPoint: responseData.priceAdjustmentForm.adjustmentReflectionPoint,
- majorApplicableRawMaterial: responseData.priceAdjustmentForm.majorApplicableRawMaterial,
- adjustmentFormula: responseData.priceAdjustmentForm.adjustmentFormula,
- rawMaterialPriceIndex: responseData.priceAdjustmentForm.rawMaterialPriceIndex,
- referenceDate: responseData.priceAdjustmentForm.referenceDate as string || null,
- comparisonDate: responseData.priceAdjustmentForm.comparisonDate as string || null,
- adjustmentRatio: responseData.priceAdjustmentForm.adjustmentRatio || null,
- notes: responseData.priceAdjustmentForm.notes,
- adjustmentConditions: responseData.priceAdjustmentForm.adjustmentConditions,
- majorNonApplicableRawMaterial: responseData.priceAdjustmentForm.majorNonApplicableRawMaterial,
- adjustmentPeriod: responseData.priceAdjustmentForm.adjustmentPeriod,
- contractorWriter: responseData.priceAdjustmentForm.contractorWriter,
- adjustmentDate: responseData.priceAdjustmentForm.adjustmentDate as string || null,
- nonApplicableReason: responseData.priceAdjustmentForm.nonApplicableReason,
- } as any
-
- // 기존 연동제 정보가 있는지 확인
- const existingPriceAdjustment = await tx
- .select()
- .from(priceAdjustmentForms)
- .where(eq(priceAdjustmentForms.companyConditionResponsesId, companyConditionResponseId))
- .limit(1)
-
- if (existingPriceAdjustment.length > 0) {
- // 업데이트
- await tx
- .update(priceAdjustmentForms)
- .set(priceAdjustmentData)
- .where(eq(priceAdjustmentForms.companyConditionResponsesId, companyConditionResponseId))
- } else {
- // 새로 생성
- await tx.insert(priceAdjustmentForms).values(priceAdjustmentData)
- }
- }
- })
-
- return {
- success: true,
- message: '임시저장이 완료되었습니다.',
- totalAmount
- }
- } catch (error) {
- console.error('Failed to save pre-quote draft:', error)
- return {
- success: false,
- error: error instanceof Error ? error.message : '임시저장에 실패했습니다.'
- }
- }
-}
-
-// 견적 문서 업로드
-export async function uploadPreQuoteDocument(
- biddingId: number,
- companyId: number,
- file: File,
- userId: string
-) {
- try {
- const userName = await getUserNameById(userId)
- // 파일 저장
- const saveResult = await saveFile({
- file,
- directory: `bidding/${biddingId}/quotations`,
- originalName: file.name,
- userId
- })
-
- if (!saveResult.success) {
- return {
- success: false,
- error: saveResult.error || '파일 저장에 실패했습니다.'
- }
- }
-
- // 데이터베이스에 문서 정보 저장
- const result = await db.insert(biddingDocuments)
- .values({
- biddingId,
- companyId,
- documentType: 'other', // 견적서 타입
- fileName: saveResult.fileName!,
- originalFileName: file.name,
- fileSize: file.size,
- mimeType: file.type,
- filePath: saveResult.publicPath!, // publicPath 사용 (웹 접근 가능한 경로)
- title: `견적서 - ${file.name}`,
- description: '협력업체 제출 견적서',
- isPublic: false,
- isRequired: false,
- uploadedBy: userName,
- uploadedAt: new Date()
- })
- .returning()
-
- return {
- success: true,
- message: '견적서가 성공적으로 업로드되었습니다.',
- documentId: result[0].id
- }
- } catch (error) {
- console.error('Failed to upload pre-quote document:', error)
- return {
- success: false,
- error: error instanceof Error ? error.message : '견적서 업로드에 실패했습니다.'
- }
- }
-}
-
-// 업로드된 견적 문서 목록 조회
-export async function getPreQuoteDocuments(biddingId: number, companyId: number) {
- try {
- const documents = await db
- .select({
- id: biddingDocuments.id,
- fileName: biddingDocuments.fileName,
- originalFileName: biddingDocuments.originalFileName,
- fileSize: biddingDocuments.fileSize,
- filePath: biddingDocuments.filePath,
- title: biddingDocuments.title,
- description: biddingDocuments.description,
- uploadedAt: biddingDocuments.uploadedAt,
- uploadedBy: biddingDocuments.uploadedBy
- })
- .from(biddingDocuments)
- .where(
- and(
- eq(biddingDocuments.biddingId, biddingId),
- eq(biddingDocuments.companyId, companyId),
- )
- )
-
- return documents
- } catch (error) {
- console.error('Failed to get pre-quote documents:', error)
- return []
- }
- }
-
-// 저장된 품목별 견적 조회 (임시저장/기존 데이터 불러오기용)
-export async function getSavedPrItemQuotations(biddingCompanyId: number) {
- try {
- const savedQuotations = await db
- .select({
- prItemId: companyPrItemBids.prItemId,
- bidUnitPrice: companyPrItemBids.bidUnitPrice,
- bidAmount: companyPrItemBids.bidAmount,
- proposedDeliveryDate: companyPrItemBids.proposedDeliveryDate,
- technicalSpecification: companyPrItemBids.technicalSpecification,
- currency: companyPrItemBids.currency
- })
- .from(companyPrItemBids)
- .where(
- and(
- eq(companyPrItemBids.biddingCompanyId, biddingCompanyId),
- eq(companyPrItemBids.isPreQuote, true)
- )
- )
-
- // Decimal 타입을 number로 변환
- return savedQuotations.map(item => ({
- prItemId: item.prItemId,
- bidUnitPrice: parseFloat(item.bidUnitPrice || '0'),
- bidAmount: parseFloat(item.bidAmount || '0'),
- proposedDeliveryDate: item.proposedDeliveryDate,
- technicalSpecification: item.technicalSpecification,
- currency: item.currency
- }))
- } catch (error) {
- console.error('Failed to get saved PR item quotations:', error)
- return []
- }
- }
-
-// 견적 문서 정보 조회 (다운로드용)
-export async function getPreQuoteDocumentForDownload(
- documentId: number,
- biddingId: number,
- companyId: number
-) {
- try {
- const document = await db
- .select({
- fileName: biddingDocuments.fileName,
- originalFileName: biddingDocuments.originalFileName,
- filePath: biddingDocuments.filePath
- })
- .from(biddingDocuments)
- .where(
- and(
- eq(biddingDocuments.id, documentId),
- eq(biddingDocuments.biddingId, biddingId),
- eq(biddingDocuments.companyId, companyId),
- eq(biddingDocuments.documentType, 'other')
- )
- )
- .limit(1)
-
- if (document.length === 0) {
- return {
- success: false,
- error: '문서를 찾을 수 없습니다.'
- }
- }
-
- return {
- success: true,
- document: document[0]
- }
- } catch (error) {
- console.error('Failed to get pre-quote document:', error)
- return {
- success: false,
- error: '문서 정보 조회에 실패했습니다.'
- }
- }
-}
-
-// 견적 문서 삭제
-export async function deletePreQuoteDocument(
- documentId: number,
- biddingId: number,
- companyId: number,
- userId: string
-) {
- try {
- // 문서 존재 여부 및 권한 확인
- const document = await db
- .select({
- id: biddingDocuments.id,
- fileName: biddingDocuments.fileName,
- filePath: biddingDocuments.filePath,
- uploadedBy: biddingDocuments.uploadedBy
- })
- .from(biddingDocuments)
- .where(
- and(
- eq(biddingDocuments.id, documentId),
- eq(biddingDocuments.biddingId, biddingId),
- eq(biddingDocuments.companyId, companyId),
- eq(biddingDocuments.documentType, 'other')
- )
- )
- .limit(1)
-
- if (document.length === 0) {
- return {
- success: false,
- error: '문서를 찾을 수 없습니다.'
- }
- }
-
- const doc = document[0]
-
- // 데이터베이스에서 문서 정보 삭제
- await db
- .delete(biddingDocuments)
- .where(eq(biddingDocuments.id, documentId))
-
- return {
- success: true,
- message: '문서가 성공적으로 삭제되었습니다.'
- }
- } catch (error) {
- console.error('Failed to delete pre-quote document:', error)
- return {
- success: false,
- error: '문서 삭제에 실패했습니다.'
- }
- }
- }
-
-// 기본계약 발송 (서버 액션)
-export async function sendBiddingBasicContracts(
- biddingId: number,
- vendorData: Array<{
- vendorId: number
- vendorName: string
- vendorCode?: string
- vendorCountry?: string
- selectedMainEmail: string
- additionalEmails: string[]
- customEmails?: Array<{ email: string; name?: string }>
- contractRequirements: {
- ndaYn: boolean
- generalGtcYn: boolean
- projectGtcYn: boolean
- agreementYn: boolean
- }
- biddingCompanyId: number
- biddingId: number
- hasExistingContracts?: boolean
- }>,
- generatedPdfs: Array<{
- key: string
- buffer: number[]
- fileName: string
- }>,
- message?: string
-) {
- try {
- console.log("sendBiddingBasicContracts called with:", { biddingId, vendorData: vendorData.map(v => ({ vendorId: v.vendorId, biddingCompanyId: v.biddingCompanyId, biddingId: v.biddingId })) });
-
- // 현재 사용자 정보 조회 (임시로 첫 번째 사용자 사용)
- const [currentUser] = await db.select().from(users).limit(1)
-
- if (!currentUser) {
- throw new Error("사용자 정보를 찾을 수 없습니다.")
- }
-
- const results = []
- const savedContracts = []
-
- // 트랜잭션 시작
- const contractsDir = path.join(process.cwd(), `${process.env.NAS_PATH}`, "contracts", "generated");
- await mkdir(contractsDir, { recursive: true });
-
- const result = await db.transaction(async (tx) => {
- // 각 벤더별로 기본계약 생성 및 이메일 발송
- for (const vendor of vendorData) {
- // 기존 계약 확인 (biddingCompanyId 기준)
- if (vendor.hasExistingContracts) {
- console.log(`벤더 ${vendor.vendorName}는 이미 계약이 체결되어 있어 건너뜁니다.`)
- continue
- }
-
- // 벤더 정보 조회
- const [vendorInfo] = await tx
- .select()
- .from(vendors)
- .where(eq(vendors.id, vendor.vendorId))
- .limit(1)
-
- if (!vendorInfo) {
- console.error(`벤더 정보를 찾을 수 없습니다: ${vendor.vendorId}`)
- continue
- }
-
- // biddingCompany 정보 조회 (biddingCompanyId를 직접 사용)
- console.log(`Looking for biddingCompany with id=${vendor.biddingCompanyId}`)
- let [biddingCompanyInfo] = await tx
- .select()
- .from(biddingCompanies)
- .where(eq(biddingCompanies.id, vendor.biddingCompanyId))
- .limit(1)
-
- console.log(`Found biddingCompanyInfo:`, biddingCompanyInfo)
- if (!biddingCompanyInfo) {
- console.error(`입찰 회사 정보를 찾을 수 없습니다: biddingCompanyId=${vendor.biddingCompanyId}`)
- // fallback: biddingId와 vendorId로 찾기 시도
- console.log(`Fallback: Looking for biddingCompany with biddingId=${biddingId}, companyId=${vendor.vendorId}`)
- const [fallbackCompanyInfo] = await tx
- .select()
- .from(biddingCompanies)
- .where(and(
- eq(biddingCompanies.biddingId, biddingId),
- eq(biddingCompanies.companyId, vendor.vendorId)
- ))
- .limit(1)
- console.log(`Fallback found biddingCompanyInfo:`, fallbackCompanyInfo)
- if (fallbackCompanyInfo) {
- console.log(`Using fallback biddingCompanyInfo`)
- biddingCompanyInfo = fallbackCompanyInfo
- } else {
- console.log(`Available biddingCompanies for biddingId=${biddingId}:`, await tx.select().from(biddingCompanies).where(eq(biddingCompanies.biddingId, biddingId)).limit(10))
- continue
- }
- }
-
- // 계약 요구사항에 따라 계약서 생성
- const contractTypes: Array<{ type: string; templateName: string }> = []
- if (vendor.contractRequirements.ndaYn) contractTypes.push({ type: 'NDA', templateName: '비밀' })
- if (vendor.contractRequirements.generalGtcYn) contractTypes.push({ type: 'General_GTC', templateName: 'General GTC' })
- if (vendor.contractRequirements.projectGtcYn) contractTypes.push({ type: 'Project_GTC', templateName: '기술' })
- if (vendor.contractRequirements.agreementYn) contractTypes.push({ type: '기술자료', templateName: '기술자료' })
- console.log("contractTypes", contractTypes)
- for (const contractType of contractTypes) {
- // PDF 데이터 찾기 (include를 사용하여 유연하게 찾기)
- console.log("generatedPdfs", generatedPdfs.map(pdf => pdf.key))
- const pdfData = generatedPdfs.find((pdf: any) =>
- pdf.key.includes(`${vendor.vendorId}_`) &&
- pdf.key.includes(`_${contractType.templateName}`)
- )
- console.log("pdfData", pdfData, "for contractType", contractType)
- if (!pdfData) {
- console.error(`PDF 데이터를 찾을 수 없습니다: vendorId=${vendor.vendorId}, templateName=${contractType.templateName}`)
- continue
- }
-
- // 파일 저장 (rfq-last 방식)
- const fileName = `${contractType.type}_${vendor.vendorCode || vendor.vendorId}_${vendor.biddingCompanyId}_${Date.now()}.pdf`
- const filePath = path.join(contractsDir, fileName);
-
- await writeFile(filePath, Buffer.from(pdfData.buffer));
-
- // 템플릿 정보 조회 (rfq-last 방식)
- const [template] = await db
- .select()
- .from(basicContractTemplates)
- .where(
- and(
- ilike(basicContractTemplates.templateName, `%${contractType.templateName}%`),
- eq(basicContractTemplates.status, "ACTIVE")
- )
- )
- .limit(1);
-
- console.log("템플릿", contractType.templateName, template);
-
- // 기존 계약이 있는지 확인 (rfq-last 방식)
- const [existingContract] = await tx
- .select()
- .from(basicContract)
- .where(
- and(
- eq(basicContract.templateId, template?.id),
- eq(basicContract.vendorId, vendor.vendorId),
- eq(basicContract.biddingCompanyId, biddingCompanyInfo.id)
- )
- )
- .limit(1);
-
- let contractRecord;
-
- if (existingContract) {
- // 기존 계약이 있으면 업데이트
- [contractRecord] = await tx
- .update(basicContract)
- .set({
- requestedBy: currentUser.id,
- status: "PENDING", // 재발송 상태
- fileName: fileName,
- filePath: `/contracts/generated/${fileName}`,
- deadline: new Date(Date.now() + 10 * 24 * 60 * 60 * 1000),
- updatedAt: new Date(),
- })
- .where(eq(basicContract.id, existingContract.id))
- .returning();
-
- console.log("기존 계약 업데이트:", contractRecord.id);
- } else {
- // 새 계약 생성
- [contractRecord] = await tx
- .insert(basicContract)
- .values({
- templateId: template?.id || null,
- vendorId: vendor.vendorId,
- biddingCompanyId: biddingCompanyInfo.id,
- rfqCompanyId: null,
- generalContractId: null,
- requestedBy: currentUser.id,
- status: 'PENDING',
- fileName: fileName,
- filePath: `/contracts/generated/${fileName}`,
- deadline: new Date(Date.now() + 10 * 24 * 60 * 60 * 1000), // 10일 후
- createdAt: new Date(),
- updatedAt: new Date(),
- })
- .returning();
-
- console.log("새 계약 생성:", contractRecord.id);
- }
-
- results.push({
- vendorId: vendor.vendorId,
- vendorName: vendor.vendorName,
- contractId: contractRecord.id,
- contractType: contractType.type,
- fileName: fileName,
- filePath: `/contracts/generated/${fileName}`,
- })
-
- // savedContracts에 추가 (rfq-last 방식)
- // savedContracts.push({
- // vendorId: vendor.vendorId,
- // vendorName: vendor.vendorName,
- // templateName: contractType.templateName,
- // contractId: contractRecord.id,
- // fileName: fileName,
- // isUpdated: !!existingContract, // 업데이트 여부 표시
- // })
- }
-
- // 이메일 발송 (선택사항)
- if (vendor.selectedMainEmail) {
- try {
- await sendEmail({
- to: vendor.selectedMainEmail,
- template: 'basic-contract-notification',
- context: {
- vendorName: vendor.vendorName,
- biddingId: biddingId,
- contractCount: contractTypes.length,
- deadline: new Date(Date.now() + 10 * 24 * 60 * 60 * 1000).toLocaleDateString('ko-KR'),
- loginUrl: `${process.env.NEXT_PUBLIC_APP_URL}/partners/bid/${biddingId}`,
- message: message || '',
- currentYear: new Date().getFullYear(),
- language: 'ko'
- }
- })
- } catch (emailError) {
- console.error(`이메일 발송 실패 (${vendor.selectedMainEmail}):`, emailError)
- // 이메일 발송 실패해도 계약 생성은 유지
- }
- }
- }
-
- return {
- success: true,
- message: `${results.length}개의 기본계약이 생성되었습니다.`,
- results,
- savedContracts,
- totalContracts: savedContracts.length,
- }
- })
-
- return result
-
- } catch (error) {
- console.error('기본계약 발송 실패:', error)
- throw new Error(
- error instanceof Error
- ? error.message
- : '기본계약 발송 중 오류가 발생했습니다.'
- )
- }
-}
-
-// 기존 기본계약 조회 (서버 액션)
-export async function getExistingBasicContractsForBidding(biddingId: number) {
- try {
- // 해당 biddingId에 속한 biddingCompany들의 기존 기본계약 조회
- const existingContracts = await db
- .select({
- id: basicContract.id,
- vendorId: basicContract.vendorId,
- biddingCompanyId: basicContract.biddingCompanyId,
- biddingId: biddingCompanies.biddingId,
- templateId: basicContract.templateId,
- status: basicContract.status,
- createdAt: basicContract.createdAt,
- })
- .from(basicContract)
- .leftJoin(biddingCompanies, eq(basicContract.biddingCompanyId, biddingCompanies.id))
- .where(
- and(
- eq(biddingCompanies.biddingId, biddingId),
- )
- )
-
- return {
- success: true,
- contracts: existingContracts
- }
-
- } catch (error) {
- console.error('기존 계약 조회 실패:', error)
- return {
- success: false,
- error: '기존 계약 조회에 실패했습니다.'
- }
- }
-}
-
-// 선정된 업체들 조회 (서버 액션)
-export async function getSelectedVendorsForBidding(biddingId: number) {
- try {
- const selectedCompanies = await db
- .select({
- id: biddingCompanies.id,
- companyId: biddingCompanies.companyId,
- companyName: vendors.vendorName,
- companyCode: vendors.vendorCode,
- companyCountry: vendors.country,
- contactPerson: biddingCompanies.contactPerson,
- contactEmail: biddingCompanies.contactEmail,
- biddingId: biddingCompanies.biddingId,
- })
- .from(biddingCompanies)
- .leftJoin(vendors, eq(biddingCompanies.companyId, vendors.id))
- .where(and(
- eq(biddingCompanies.biddingId, biddingId),
- eq(biddingCompanies.isPreQuoteSelected, true)
- ))
-
- return {
- success: true,
- vendors: selectedCompanies.map(company => ({
- vendorId: company.companyId, // 실제 vendor ID
- vendorName: company.companyName || '',
- vendorCode: company.companyCode,
- vendorCountry: company.companyCountry || '대한민국',
- contactPerson: company.contactPerson,
- contactEmail: company.contactEmail,
- biddingCompanyId: company.id, // biddingCompany ID
- biddingId: company.biddingId,
- ndaYn: true, // 모든 계약 타입을 활성화 (필요에 따라 조정)
- generalGtcYn: true,
- projectGtcYn: true,
- agreementYn: true
- }))
- }
- } catch (error) {
- console.error('선정된 업체 조회 실패:', error)
- return {
- success: false,
- error: '선정된 업체 조회에 실패했습니다.',
- vendors: []
- }
- }
+'use server'
+
+import db from '@/db/db'
+import { biddingCompanies, companyConditionResponses, biddings, prItemsForBidding, biddingDocuments, companyPrItemBids, priceAdjustmentForms } from '@/db/schema/bidding'
+import { basicContractTemplates } from '@/db/schema'
+import { vendors } from '@/db/schema/vendors'
+import { users } from '@/db/schema'
+import { sendEmail } from '@/lib/mail/sendEmail'
+import { eq, inArray, and, ilike, sql } from 'drizzle-orm'
+import { mkdir, writeFile } from 'fs/promises'
+import path from 'path'
+import { revalidateTag, revalidatePath } from 'next/cache'
+import { basicContract } from '@/db/schema/basicContractDocumnet'
+import { saveFile } from '@/lib/file-stroage'
+
+// userId를 user.name으로 변환하는 유틸리티 함수
+async function getUserNameById(userId: string): Promise<string> {
+ try {
+ const user = await db
+ .select({ name: users.name })
+ .from(users)
+ .where(eq(users.id, parseInt(userId)))
+ .limit(1)
+
+ return user[0]?.name || userId // user.name이 없으면 userId를 그대로 반환
+ } catch (error) {
+ console.error('Failed to get user name:', error)
+ return userId // 에러 시 userId를 그대로 반환
+ }
+}
+
+interface CreateBiddingCompanyInput {
+ biddingId: number
+ companyId: number
+ contactPerson?: string
+ contactEmail?: string
+ contactPhone?: string
+ notes?: string
+}
+
+interface UpdateBiddingCompanyInput {
+ contactPerson?: string
+ contactEmail?: string
+ contactPhone?: string
+ preQuoteAmount?: number
+ notes?: string
+ invitationStatus?: 'pending' | 'pre_quote_sent' | 'pre_quote_accepted' | 'pre_quote_declined' | 'pre_quote_submitted' | 'bidding_sent' | 'bidding_accepted' | 'bidding_declined' | 'bidding_cancelled' | 'bidding_submitted'
+ isPreQuoteSelected?: boolean
+ isAttendingMeeting?: boolean
+}
+
+interface PrItemQuotation {
+ prItemId: number
+ bidUnitPrice: number
+ bidAmount: number
+ proposedDeliveryDate?: string
+ technicalSpecification?: string
+}
+
+
+
+ // 사전견적용 업체 추가 - biddingCompanies와 company_condition_responses 레코드 생성
+export async function createBiddingCompany(input: CreateBiddingCompanyInput) {
+ try {
+ const result = await db.transaction(async (tx) => {
+ // 0. 중복 체크 - 이미 해당 입찰에 참여중인 업체인지 확인
+ const existingCompany = await tx
+ .select()
+ .from(biddingCompanies)
+ .where(sql`${biddingCompanies.biddingId} = ${input.biddingId} AND ${biddingCompanies.companyId} = ${input.companyId}`)
+
+ if (existingCompany.length > 0) {
+ throw new Error('이미 등록된 업체입니다')
+ }
+ // 1. biddingCompanies 레코드 생성
+ const biddingCompanyResult = await tx.insert(biddingCompanies).values({
+ biddingId: input.biddingId,
+ companyId: input.companyId,
+ invitationStatus: 'pending', // 초기 상태: 초대 대기
+ invitedAt: new Date(),
+ contactPerson: input.contactPerson,
+ contactEmail: input.contactEmail,
+ contactPhone: input.contactPhone,
+ notes: input.notes,
+ }).returning({ id: biddingCompanies.id })
+
+ if (biddingCompanyResult.length === 0) {
+ throw new Error('업체 추가에 실패했습니다.')
+ }
+
+ const biddingCompanyId = biddingCompanyResult[0].id
+
+ // 2. company_condition_responses 레코드 생성 (기본값으로)
+ await tx.insert(companyConditionResponses).values({
+ biddingCompanyId: biddingCompanyId,
+ // 나머지 필드들은 null로 시작 (벤더가 나중에 응답)
+ })
+
+ return biddingCompanyId
+ })
+
+ return {
+ success: true,
+ message: '업체가 성공적으로 추가되었습니다.',
+ data: { id: result }
+ }
+ } catch (error) {
+ console.error('Failed to create bidding company:', error)
+ return {
+ success: false,
+ error: error instanceof Error ? error.message : '업체 추가에 실패했습니다.'
+ }
+ }
+}
+
+// 사전견적용 업체 정보 업데이트
+export async function updateBiddingCompany(id: number, input: UpdateBiddingCompanyInput) {
+ try {
+ const updateData: any = {
+ updatedAt: new Date()
+ }
+
+ if (input.contactPerson !== undefined) updateData.contactPerson = input.contactPerson
+ if (input.contactEmail !== undefined) updateData.contactEmail = input.contactEmail
+ if (input.contactPhone !== undefined) updateData.contactPhone = input.contactPhone
+ if (input.preQuoteAmount !== undefined) updateData.preQuoteAmount = input.preQuoteAmount
+ if (input.notes !== undefined) updateData.notes = input.notes
+ if (input.invitationStatus !== undefined) {
+ updateData.invitationStatus = input.invitationStatus
+ if (input.invitationStatus !== 'pending') {
+ updateData.respondedAt = new Date()
+ }
+ }
+ if (input.isPreQuoteSelected !== undefined) updateData.isPreQuoteSelected = input.isPreQuoteSelected
+ if (input.isAttendingMeeting !== undefined) updateData.isAttendingMeeting = input.isAttendingMeeting
+
+ await db.update(biddingCompanies)
+ .set(updateData)
+ .where(eq(biddingCompanies.id, id))
+
+ return {
+ success: true,
+ message: '업체 정보가 성공적으로 업데이트되었습니다.',
+ }
+ } catch (error) {
+ console.error('Failed to update bidding company:', error)
+ return {
+ success: false,
+ error: error instanceof Error ? error.message : '업체 정보 업데이트에 실패했습니다.'
+ }
+ }
+}
+
+// 본입찰 등록 상태 업데이트 (복수 업체 선택 가능)
+export async function updatePreQuoteSelection(companyIds: number[], isSelected: boolean) {
+ try {
+ // 업체들의 입찰 ID 조회 (캐시 무효화를 위해)
+ const companies = await db
+ .select({ biddingId: biddingCompanies.biddingId })
+ .from(biddingCompanies)
+ .where(inArray(biddingCompanies.id, companyIds))
+ .limit(1)
+
+ await db.update(biddingCompanies)
+ .set({
+ isPreQuoteSelected: isSelected,
+ invitationStatus: 'pending', // 초기 상태: 초대 대기
+ updatedAt: new Date()
+ })
+ .where(inArray(biddingCompanies.id, companyIds))
+
+ // 캐시 무효화
+ if (companies.length > 0) {
+ const biddingId = companies[0].biddingId
+ revalidateTag(`bidding-${biddingId}`)
+ revalidateTag('bidding-detail')
+ revalidateTag('quotation-vendors')
+ revalidateTag('quotation-details')
+ revalidatePath(`/evcp/bid/${biddingId}`)
+ }
+
+ const message = isSelected
+ ? `${companyIds.length}개 업체가 본입찰 대상으로 선정되었습니다.`
+ : `${companyIds.length}개 업체의 본입찰 선정이 취소되었습니다.`
+
+ return {
+ success: true,
+ message
+ }
+ } catch (error) {
+ console.error('Failed to update pre-quote selection:', error)
+ return {
+ success: false,
+ error: error instanceof Error ? error.message : '본입찰 선정 상태 업데이트에 실패했습니다.'
+ }
+ }
+}
+
+// 사전견적용 업체 삭제
+export async function deleteBiddingCompany(id: number) {
+ try {
+ // 1. 해당 업체의 초대 상태 확인
+ const company = await db
+ .select({ invitationStatus: biddingCompanies.invitationStatus })
+ .from(biddingCompanies)
+ .where(eq(biddingCompanies.id, id))
+ .then(rows => rows[0])
+
+ if (!company) {
+ return {
+ success: false,
+ error: '해당 업체를 찾을 수 없습니다.'
+ }
+ }
+
+ // 이미 초대가 발송된 경우(수락, 거절, 요청됨 등) 삭제 불가
+ if (company.invitationStatus !== 'pending') {
+ return {
+ success: false,
+ error: '이미 초대를 보낸 업체는 삭제할 수 없습니다.'
+ }
+ }
+
+ await db.transaction(async (tx) => {
+ // 2. 먼저 관련된 조건 응답들 삭제
+ await tx.delete(companyConditionResponses)
+ .where(eq(companyConditionResponses.biddingCompanyId, id))
+
+ // 3. biddingCompanies 레코드 삭제
+ await tx.delete(biddingCompanies)
+ .where(eq(biddingCompanies.id, id))
+ })
+
+ return {
+ success: true,
+ message: '업체가 성공적으로 삭제되었습니다.'
+ }
+ } catch (error) {
+ console.error('Failed to delete bidding company:', error)
+ return {
+ success: false,
+ error: error instanceof Error ? error.message : '업체 삭제에 실패했습니다.'
+ }
+ }
+}
+
+// 특정 입찰의 참여 업체 목록 조회 (company_condition_responses와 vendors 조인)
+export async function getBiddingCompanies(biddingId: number) {
+ try {
+ const companies = await db
+ .select({
+ // bidding_companies 필드들
+ id: biddingCompanies.id,
+ biddingId: biddingCompanies.biddingId,
+ companyId: biddingCompanies.companyId,
+ invitationStatus: biddingCompanies.invitationStatus,
+ invitedAt: biddingCompanies.invitedAt,
+ respondedAt: biddingCompanies.respondedAt,
+ preQuoteAmount: biddingCompanies.preQuoteAmount,
+ preQuoteSubmittedAt: biddingCompanies.preQuoteSubmittedAt,
+ preQuoteDeadline: biddingCompanies.preQuoteDeadline,
+ isPreQuoteSelected: biddingCompanies.isPreQuoteSelected,
+ isPreQuoteParticipated: biddingCompanies.isPreQuoteParticipated,
+ isAttendingMeeting: biddingCompanies.isAttendingMeeting,
+ notes: biddingCompanies.notes,
+ contactPerson: biddingCompanies.contactPerson,
+ contactEmail: biddingCompanies.contactEmail,
+ contactPhone: biddingCompanies.contactPhone,
+ createdAt: biddingCompanies.createdAt,
+ updatedAt: biddingCompanies.updatedAt,
+
+ // vendors 테이블에서 업체 정보
+ companyName: vendors.vendorName,
+ companyCode: vendors.vendorCode,
+ companyEmail: vendors.email, // 벤더의 기본 이메일
+
+ // company_condition_responses 필드들
+ paymentTermsResponse: companyConditionResponses.paymentTermsResponse,
+ taxConditionsResponse: companyConditionResponses.taxConditionsResponse,
+ proposedContractDeliveryDate: companyConditionResponses.proposedContractDeliveryDate,
+ priceAdjustmentResponse: companyConditionResponses.priceAdjustmentResponse,
+ isInitialResponse: companyConditionResponses.isInitialResponse,
+ incotermsResponse: companyConditionResponses.incotermsResponse,
+ proposedShippingPort: companyConditionResponses.proposedShippingPort,
+ proposedDestinationPort: companyConditionResponses.proposedDestinationPort,
+ sparePartResponse: companyConditionResponses.sparePartResponse,
+ additionalProposals: companyConditionResponses.additionalProposals,
+ })
+ .from(biddingCompanies)
+ .leftJoin(
+ vendors,
+ eq(biddingCompanies.companyId, vendors.id)
+ )
+ .leftJoin(
+ companyConditionResponses,
+ eq(biddingCompanies.id, companyConditionResponses.biddingCompanyId)
+ )
+ .where(eq(biddingCompanies.biddingId, biddingId))
+
+ // 디버깅: 서버에서 가져온 데이터 확인
+ console.log('=== getBiddingCompanies Server Log ===')
+ console.log('Total companies:', companies.length)
+ if (companies.length > 0) {
+ console.log('First company:', {
+ companyName: companies[0].companyName,
+ companyEmail: companies[0].companyEmail,
+ companyCode: companies[0].companyCode,
+ companyId: companies[0].companyId
+ })
+ }
+ console.log('======================================')
+
+ return {
+ success: true,
+ data: companies
+ }
+ } catch (error) {
+ console.error('Failed to get bidding companies:', error)
+ return {
+ success: false,
+ error: error instanceof Error ? error.message : '업체 목록 조회에 실패했습니다.'
+ }
+ }
+}
+
+// 선택된 업체들에게 사전견적 초대 발송
+interface CompanyWithContacts {
+ id: number
+ companyId: number
+ companyName: string
+ selectedMainEmail: string
+ additionalEmails: string[]
+}
+
+export async function sendPreQuoteInvitations(companiesData: CompanyWithContacts[], preQuoteDeadline?: Date | string) {
+ try {
+ console.log('=== sendPreQuoteInvitations called ===');
+ console.log('companiesData:', JSON.stringify(companiesData, null, 2));
+
+ if (companiesData.length === 0) {
+ return {
+ success: false,
+ error: '선택된 업체가 없습니다.'
+ }
+ }
+
+ const companyIds = companiesData.map(c => c.id);
+ console.log('companyIds:', companyIds);
+
+ // 선택된 업체들의 정보와 입찰 정보 조회
+ const companiesInfo = await db
+ .select({
+ biddingCompanyId: biddingCompanies.id,
+ companyId: biddingCompanies.companyId,
+ biddingId: biddingCompanies.biddingId,
+ companyName: vendors.vendorName,
+ companyEmail: vendors.email,
+ // 입찰 정보
+ biddingNumber: biddings.biddingNumber,
+ revision: biddings.revision,
+ projectName: biddings.projectName,
+ biddingTitle: biddings.title,
+ itemName: biddings.itemName,
+ preQuoteDate: biddings.preQuoteDate,
+ budget: biddings.budget,
+ currency: biddings.currency,
+ bidPicName: biddings.bidPicName,
+ supplyPicName: biddings.supplyPicName,
+ })
+ .from(biddingCompanies)
+ .leftJoin(vendors, eq(biddingCompanies.companyId, vendors.id))
+ .leftJoin(biddings, eq(biddingCompanies.biddingId, biddings.id))
+ .where(inArray(biddingCompanies.id, companyIds))
+
+ console.log('companiesInfo fetched:', JSON.stringify(companiesInfo, null, 2));
+
+ if (companiesInfo.length === 0) {
+ return {
+ success: false,
+ error: '업체 정보를 찾을 수 없습니다.'
+ }
+ }
+
+ // 모든 필드가 null이 아닌지 확인하고 안전하게 변환
+ const safeCompaniesInfo = companiesInfo.map(company => ({
+ ...company,
+ companyName: company.companyName ?? '',
+ companyEmail: company.companyEmail ?? '',
+ biddingNumber: company.biddingNumber ?? '',
+ revision: company.revision ?? '',
+ projectName: company.projectName ?? '',
+ biddingTitle: company.biddingTitle ?? '',
+ itemName: company.itemName ?? '',
+ preQuoteDate: company.preQuoteDate ?? null,
+ budget: company.budget ?? null,
+ currency: company.currency ?? '',
+ bidPicName: company.bidPicName ?? '',
+ supplyPicName: company.supplyPicName ?? '',
+ }));
+
+ console.log('safeCompaniesInfo prepared:', JSON.stringify(safeCompaniesInfo, null, 2));
+
+ await db.transaction(async (tx) => {
+ // 선택된 업체들의 상태를 '사전견적 초대 발송'으로 변경
+ for (const id of companyIds) {
+ await tx.update(biddingCompanies)
+ .set({
+ invitationStatus: 'pre_quote_sent', // 사전견적 초대 발송 상태
+ invitedAt: new Date(),
+ preQuoteDeadline: preQuoteDeadline ? new Date(preQuoteDeadline) : null,
+ updatedAt: new Date()
+ })
+ .where(eq(biddingCompanies.id, id))
+ }
+ })
+
+ // 각 업체별로 이메일 발송 (담당자 정보 포함)
+ console.log('=== Starting email sending ===');
+ for (const company of safeCompaniesInfo) {
+ console.log(`Processing company: ${company.companyName} (biddingCompanyId: ${company.biddingCompanyId})`);
+
+ const companyData = companiesData.find(c => c.id === company.biddingCompanyId);
+ if (!companyData) {
+ console.log(`No companyData found for biddingCompanyId: ${company.biddingCompanyId}`);
+ continue;
+ }
+
+ console.log('companyData found:', JSON.stringify(companyData, null, 2));
+
+ const mainEmail = companyData.selectedMainEmail || '';
+ const ccEmails = Array.isArray(companyData.additionalEmails) ? companyData.additionalEmails : [];
+
+ console.log(`mainEmail: ${mainEmail}, ccEmails: ${JSON.stringify(ccEmails)}`);
+
+ if (mainEmail) {
+ try {
+ console.log('Preparing to send email...');
+
+ const emailContext = {
+ companyName: company.companyName,
+ biddingNumber: company.biddingNumber,
+ revision: company.revision,
+ projectName: company.projectName,
+ biddingTitle: company.biddingTitle,
+ itemName: company.itemName,
+ preQuoteDate: company.preQuoteDate ? new Date(company.preQuoteDate).toLocaleDateString() : '',
+ budget: company.budget ? String(company.budget) : '',
+ currency: company.currency,
+ bidPicName: company.bidPicName,
+ supplyPicName: company.supplyPicName,
+ loginUrl: `${process.env.NEXT_PUBLIC_APP_URL}/partners/bid/${company.biddingId}/pre-quote`,
+ currentYear: new Date().getFullYear(),
+ language: 'ko'
+ };
+
+ console.log('Email context prepared:', JSON.stringify(emailContext, null, 2));
+
+ await sendEmail({
+ to: mainEmail,
+ cc: ccEmails.length > 0 ? ccEmails : undefined,
+ template: 'pre-quote-invitation',
+ context: emailContext
+ })
+
+ console.log(`Email sent successfully to ${mainEmail}`);
+ } catch (emailError) {
+ console.error(`Failed to send email to ${mainEmail}:`, emailError)
+ // 이메일 발송 실패해도 전체 프로세스는 계속 진행
+ }
+ }
+ }
+ // 3. 입찰 상태를 사전견적 요청으로 변경 (bidding_generated 상태에서만)
+ for (const company of companiesInfo) {
+ await db.transaction(async (tx) => {
+ await tx
+ .update(biddings)
+ .set({
+ status: 'request_for_quotation',
+ updatedAt: new Date()
+ })
+ .where(and(
+ eq(biddings.id, company.biddingId),
+ eq(biddings.status, 'bidding_generated')
+ ))
+ })
+ }
+ return {
+ success: true,
+ message: `${companyIds.length}개 업체에 사전견적 초대를 발송했습니다.`
+ }
+ } catch (error) {
+ console.error('Failed to send pre-quote invitations:', error)
+ return {
+ success: false,
+ error: error instanceof Error ? error.message : '초대 발송에 실패했습니다.'
+ }
+ }
+}
+
+// Partners에서 특정 업체의 입찰 정보 조회 (사전견적 단계)
+export async function getBiddingCompaniesForPartners(biddingId: number, companyId: number) {
+ try {
+ // 1. 먼저 입찰 기본 정보를 가져옴
+ const biddingResult = await db
+ .select({
+ id: biddings.id,
+ biddingNumber: biddings.biddingNumber,
+ revision: biddings.revision,
+ projectName: biddings.projectName,
+ itemName: biddings.itemName,
+ title: biddings.title,
+ description: biddings.description,
+ contractType: biddings.contractType,
+ biddingType: biddings.biddingType,
+ awardCount: biddings.awardCount,
+ contractStartDate: biddings.contractStartDate,
+ contractEndDate: biddings.contractEndDate,
+ preQuoteDate: biddings.preQuoteDate,
+ biddingRegistrationDate: biddings.biddingRegistrationDate,
+ submissionStartDate: biddings.submissionStartDate,
+ submissionEndDate: biddings.submissionEndDate,
+ evaluationDate: biddings.evaluationDate,
+ currency: biddings.currency,
+ budget: biddings.budget,
+ targetPrice: biddings.targetPrice,
+ status: biddings.status,
+ bidPicName: biddings.bidPicName,
+ supplyPicName: biddings.supplyPicName,
+ })
+ .from(biddings)
+ .where(eq(biddings.id, biddingId))
+ .limit(1)
+
+ if (biddingResult.length === 0) {
+ return null
+ }
+
+ const biddingData = biddingResult[0]
+
+ // 2. 해당 업체의 biddingCompanies 정보 조회
+ const companyResult = await db
+ .select({
+ biddingCompanyId: biddingCompanies.id,
+ biddingId: biddingCompanies.biddingId,
+ invitationStatus: biddingCompanies.invitationStatus,
+ preQuoteAmount: biddingCompanies.preQuoteAmount,
+ preQuoteSubmittedAt: biddingCompanies.preQuoteSubmittedAt,
+ preQuoteDeadline: biddingCompanies.preQuoteDeadline,
+ isPreQuoteSelected: biddingCompanies.isPreQuoteSelected,
+ isPreQuoteParticipated: biddingCompanies.isPreQuoteParticipated,
+ isAttendingMeeting: biddingCompanies.isAttendingMeeting,
+ // company_condition_responses 정보
+ paymentTermsResponse: companyConditionResponses.paymentTermsResponse,
+ taxConditionsResponse: companyConditionResponses.taxConditionsResponse,
+ incotermsResponse: companyConditionResponses.incotermsResponse,
+ proposedContractDeliveryDate: companyConditionResponses.proposedContractDeliveryDate,
+ proposedShippingPort: companyConditionResponses.proposedShippingPort,
+ proposedDestinationPort: companyConditionResponses.proposedDestinationPort,
+ priceAdjustmentResponse: companyConditionResponses.priceAdjustmentResponse,
+ sparePartResponse: companyConditionResponses.sparePartResponse,
+ isInitialResponse: companyConditionResponses.isInitialResponse,
+ additionalProposals: companyConditionResponses.additionalProposals,
+ })
+ .from(biddingCompanies)
+ .leftJoin(
+ companyConditionResponses,
+ eq(biddingCompanies.id, companyConditionResponses.biddingCompanyId)
+ )
+ .where(
+ and(
+ eq(biddingCompanies.biddingId, biddingId),
+ eq(biddingCompanies.companyId, companyId)
+ )
+ )
+ .limit(1)
+
+ // 3. 결과 조합
+ if (companyResult.length === 0) {
+ // 아직 초대되지 않은 상태
+ return {
+ ...biddingData,
+ biddingCompanyId: null,
+ biddingId: biddingData.id,
+ invitationStatus: null,
+ preQuoteAmount: null,
+ preQuoteSubmittedAt: null,
+ preQuoteDeadline: null,
+ isPreQuoteSelected: false,
+ isPreQuoteParticipated: null,
+ isAttendingMeeting: null,
+ paymentTermsResponse: null,
+ taxConditionsResponse: null,
+ incotermsResponse: null,
+ proposedContractDeliveryDate: null,
+ proposedShippingPort: null,
+ proposedDestinationPort: null,
+ priceAdjustmentResponse: null,
+ sparePartResponse: null,
+ isInitialResponse: null,
+ additionalProposals: null,
+ }
+ }
+
+ const companyData = companyResult[0]
+
+ return {
+ ...biddingData,
+ ...companyData,
+ biddingId: biddingData.id, // bidding ID 보장
+ }
+ } catch (error) {
+ console.error('Failed to get bidding companies for partners:', error)
+ throw error
+ }
+}
+
+// Partners에서 사전견적 응답 제출
+export async function submitPreQuoteResponse(
+ biddingCompanyId: number,
+ responseData: {
+ preQuoteAmount?: number // 품목별 계산에서 자동으로 계산되므로 optional
+ prItemQuotations?: PrItemQuotation[] // 품목별 견적 정보 추가
+ paymentTermsResponse?: string
+ taxConditionsResponse?: string
+ incotermsResponse?: string
+ proposedContractDeliveryDate?: string
+ proposedShippingPort?: string
+ proposedDestinationPort?: string
+ priceAdjustmentResponse?: boolean
+ isInitialResponse?: boolean
+ sparePartResponse?: string
+ additionalProposals?: string
+ priceAdjustmentForm?: any
+ },
+ userId: string
+) {
+ try {
+ let finalAmount = responseData.preQuoteAmount || 0
+
+ await db.transaction(async (tx) => {
+ // 1. 품목별 견적 정보 최종 저장 (사전견적 제출)
+ if (responseData.prItemQuotations && responseData.prItemQuotations.length > 0) {
+ // 기존 사전견적 품목 삭제 후 새로 생성
+ await tx.delete(companyPrItemBids)
+ .where(
+ and(
+ eq(companyPrItemBids.biddingCompanyId, biddingCompanyId),
+ eq(companyPrItemBids.isPreQuote, true)
+ )
+ )
+
+ // 품목별 견적 최종 저장
+ for (const item of responseData.prItemQuotations) {
+ await tx.insert(companyPrItemBids)
+ .values({
+ biddingCompanyId,
+ prItemId: item.prItemId,
+ bidUnitPrice: item.bidUnitPrice.toString(),
+ bidAmount: item.bidAmount.toString(),
+ proposedDeliveryDate: item.proposedDeliveryDate || null,
+ technicalSpecification: item.technicalSpecification || null,
+ currency: 'KRW',
+ isPreQuote: true,
+ submittedAt: new Date(),
+ createdAt: new Date(),
+ updatedAt: new Date()
+ })
+ }
+
+ // 총 금액 다시 계산
+ finalAmount = responseData.prItemQuotations.reduce((sum, item) => sum + item.bidAmount, 0)
+ }
+
+ // 2. biddingCompanies 업데이트 (사전견적 금액, 제출 시간, 상태 변경)
+ await tx.update(biddingCompanies)
+ .set({
+ preQuoteAmount: finalAmount.toString(),
+ preQuoteSubmittedAt: new Date(),
+ invitationStatus: 'pre_quote_submitted', // 사전견적제출완료 상태로 변경
+ updatedAt: new Date()
+ })
+ .where(eq(biddingCompanies.id, biddingCompanyId))
+
+ // 3. company_condition_responses 업데이트
+ const finalConditionResult = await tx.update(companyConditionResponses)
+ .set({
+ paymentTermsResponse: responseData.paymentTermsResponse,
+ taxConditionsResponse: responseData.taxConditionsResponse,
+ incotermsResponse: responseData.incotermsResponse,
+ proposedContractDeliveryDate: responseData.proposedContractDeliveryDate,
+ proposedShippingPort: responseData.proposedShippingPort,
+ proposedDestinationPort: responseData.proposedDestinationPort,
+ priceAdjustmentResponse: responseData.priceAdjustmentResponse,
+ isInitialResponse: responseData.isInitialResponse,
+ sparePartResponse: responseData.sparePartResponse,
+ additionalProposals: responseData.additionalProposals,
+ updatedAt: new Date()
+ })
+ .where(eq(companyConditionResponses.biddingCompanyId, biddingCompanyId))
+ .returning()
+
+ // 4. 연동제 정보 저장 (연동제 적용이 true이고 연동제 정보가 있는 경우)
+ if (responseData.priceAdjustmentResponse && responseData.priceAdjustmentForm && finalConditionResult.length > 0) {
+ const companyConditionResponseId = finalConditionResult[0].id
+
+ const priceAdjustmentData = {
+ companyConditionResponsesId: companyConditionResponseId,
+ itemName: responseData.priceAdjustmentForm.itemName,
+ adjustmentReflectionPoint: responseData.priceAdjustmentForm.adjustmentReflectionPoint,
+ majorApplicableRawMaterial: responseData.priceAdjustmentForm.majorApplicableRawMaterial,
+ adjustmentFormula: responseData.priceAdjustmentForm.adjustmentFormula,
+ rawMaterialPriceIndex: responseData.priceAdjustmentForm.rawMaterialPriceIndex,
+ referenceDate: responseData.priceAdjustmentForm.referenceDate as string || null,
+ comparisonDate: responseData.priceAdjustmentForm.comparisonDate as string || null,
+ adjustmentRatio: responseData.priceAdjustmentForm.adjustmentRatio || null,
+ notes: responseData.priceAdjustmentForm.notes,
+ adjustmentConditions: responseData.priceAdjustmentForm.adjustmentConditions,
+ majorNonApplicableRawMaterial: responseData.priceAdjustmentForm.majorNonApplicableRawMaterial,
+ adjustmentPeriod: responseData.priceAdjustmentForm.adjustmentPeriod,
+ contractorWriter: responseData.priceAdjustmentForm.contractorWriter,
+ adjustmentDate: responseData.priceAdjustmentForm.adjustmentDate as string || null,
+ nonApplicableReason: responseData.priceAdjustmentForm.nonApplicableReason,
+ } as any
+
+ // 기존 연동제 정보가 있는지 확인
+ const existingPriceAdjustment = await tx
+ .select()
+ .from(priceAdjustmentForms)
+ .where(eq(priceAdjustmentForms.companyConditionResponsesId, companyConditionResponseId))
+ .limit(1)
+
+ if (existingPriceAdjustment.length > 0) {
+ // 업데이트
+ await tx
+ .update(priceAdjustmentForms)
+ .set(priceAdjustmentData)
+ .where(eq(priceAdjustmentForms.companyConditionResponsesId, companyConditionResponseId))
+ } else {
+ // 새로 생성
+ await tx.insert(priceAdjustmentForms).values(priceAdjustmentData)
+ }
+ }
+
+ // 5. 입찰 상태를 사전견적 접수로 변경 (request_for_quotation 상태에서만)
+ // 또한 사전견적 접수일 업데이트
+ const biddingCompany = await tx
+ .select({ biddingId: biddingCompanies.biddingId })
+ .from(biddingCompanies)
+ .where(eq(biddingCompanies.id, biddingCompanyId))
+ .limit(1)
+
+ if (biddingCompany.length > 0) {
+ await tx
+ .update(biddings)
+ .set({
+ status: 'received_quotation',
+ preQuoteDate: new Date().toISOString().split('T')[0], // 사전견적 접수일 업데이트
+ updatedAt: new Date()
+ })
+ .where(and(
+ eq(biddings.id, biddingCompany[0].biddingId),
+ eq(biddings.status, 'request_for_quotation')
+ ))
+ }
+ })
+
+ return {
+ success: true,
+ message: '사전견적이 성공적으로 제출되었습니다.'
+ }
+ } catch (error) {
+ console.error('Failed to submit pre-quote response:', error)
+ return {
+ success: false,
+ error: error instanceof Error ? error.message : '사전견적 제출에 실패했습니다.'
+ }
+ }
+}
+
+// Partners에서 사전견적 참여 의사 결정 (참여/미참여)
+export async function respondToPreQuoteInvitation(
+ biddingCompanyId: number,
+ response: 'pre_quote_accepted' | 'pre_quote_declined'
+) {
+ try {
+ await db.update(biddingCompanies)
+ .set({
+ invitationStatus: response, // pre_quote_accepted 또는 pre_quote_declined
+ respondedAt: new Date(),
+ updatedAt: new Date()
+ })
+ .where(eq(biddingCompanies.id, biddingCompanyId))
+
+ const message = response === 'pre_quote_accepted' ?
+ '사전견적 참여를 수락했습니다.' :
+ '사전견적 참여를 거절했습니다.'
+
+ return {
+ success: true,
+ message
+ }
+ } catch (error) {
+ console.error('Failed to respond to pre-quote invitation:', error)
+ return {
+ success: false,
+ error: error instanceof Error ? error.message : '응답 처리에 실패했습니다.'
+ }
+ }
+}
+
+// 벤더에서 사전견적 참여 여부 결정 (isPreQuoteParticipated 사용)
+export async function setPreQuoteParticipation(
+ biddingCompanyId: number,
+ isParticipating: boolean
+) {
+ try {
+ await db.update(biddingCompanies)
+ .set({
+ isPreQuoteParticipated: isParticipating,
+ respondedAt: new Date(),
+ updatedAt: new Date()
+ })
+ .where(eq(biddingCompanies.id, biddingCompanyId))
+
+ const message = isParticipating ?
+ '사전견적 참여를 확정했습니다. 이제 견적서를 작성하실 수 있습니다.' :
+ '사전견적 참여를 거절했습니다.'
+
+ return {
+ success: true,
+ message
+ }
+ } catch (error) {
+ console.error('Failed to set pre-quote participation:', error)
+ return {
+ success: false,
+ error: error instanceof Error ? error.message : '참여 의사 처리에 실패했습니다.'
+ }
+ }
+}
+
+// PR 아이템 조회 (입찰에 포함된 품목들)
+export async function getPrItemsForBidding(biddingId: number) {
+ try {
+ const prItems = await db
+ .select({
+ id: prItemsForBidding.id,
+ biddingId: prItemsForBidding.biddingId,
+ itemNumber: prItemsForBidding.itemNumber,
+ projectId: prItemsForBidding.projectId,
+ projectInfo: prItemsForBidding.projectInfo,
+ itemInfo: prItemsForBidding.itemInfo,
+ shi: prItemsForBidding.shi,
+ materialGroupNumber: prItemsForBidding.materialGroupNumber,
+ materialGroupInfo: prItemsForBidding.materialGroupInfo,
+ materialNumber: prItemsForBidding.materialNumber,
+ materialInfo: prItemsForBidding.materialInfo,
+ requestedDeliveryDate: prItemsForBidding.requestedDeliveryDate,
+ annualUnitPrice: prItemsForBidding.annualUnitPrice,
+ currency: prItemsForBidding.currency,
+ quantity: prItemsForBidding.quantity,
+ quantityUnit: prItemsForBidding.quantityUnit,
+ totalWeight: prItemsForBidding.totalWeight,
+ weightUnit: prItemsForBidding.weightUnit,
+ priceUnit: prItemsForBidding.priceUnit,
+ purchaseUnit: prItemsForBidding.purchaseUnit,
+ materialWeight: prItemsForBidding.materialWeight,
+ prNumber: prItemsForBidding.prNumber,
+ hasSpecDocument: prItemsForBidding.hasSpecDocument,
+ })
+ .from(prItemsForBidding)
+ .where(eq(prItemsForBidding.biddingId, biddingId))
+
+ return prItems
+ } catch (error) {
+ console.error('Failed to get PR items for bidding:', error)
+ return []
+ }
+}
+
+// SPEC 문서 조회 (PR 아이템에 연결된 문서들)
+export async function getSpecDocumentsForPrItem(prItemId: number) {
+ try {
+
+ const specDocs = await db
+ .select({
+ id: biddingDocuments.id,
+ fileName: biddingDocuments.fileName,
+ originalFileName: biddingDocuments.originalFileName,
+ fileSize: biddingDocuments.fileSize,
+ filePath: biddingDocuments.filePath,
+ title: biddingDocuments.title,
+ description: biddingDocuments.description,
+ uploadedAt: biddingDocuments.uploadedAt
+ })
+ .from(biddingDocuments)
+ .where(
+ and(
+ eq(biddingDocuments.prItemId, prItemId),
+ eq(biddingDocuments.documentType, 'spec_document')
+ )
+ )
+
+ return specDocs
+ } catch (error) {
+ console.error('Failed to get spec documents for PR item:', error)
+ return []
+ }
+}
+
+// 사전견적 임시저장
+export async function savePreQuoteDraft(
+ biddingCompanyId: number,
+ responseData: {
+ prItemQuotations?: PrItemQuotation[]
+ paymentTermsResponse?: string
+ taxConditionsResponse?: string
+ incotermsResponse?: string
+ proposedContractDeliveryDate?: string
+ proposedShippingPort?: string
+ proposedDestinationPort?: string
+ priceAdjustmentResponse?: boolean
+ isInitialResponse?: boolean
+ sparePartResponse?: string
+ additionalProposals?: string
+ priceAdjustmentForm?: any
+ },
+ userId: string
+) {
+ try {
+ let totalAmount = 0
+ console.log('responseData', responseData)
+
+ await db.transaction(async (tx) => {
+ // 품목별 견적 정보 저장
+ if (responseData.prItemQuotations && responseData.prItemQuotations.length > 0) {
+ // 기존 사전견적 품목 삭제 (임시저장 시 덮어쓰기)
+ await tx.delete(companyPrItemBids)
+ .where(
+ and(
+ eq(companyPrItemBids.biddingCompanyId, biddingCompanyId),
+ eq(companyPrItemBids.isPreQuote, true)
+ )
+ )
+
+ // 새로운 품목별 견적 저장
+ for (const item of responseData.prItemQuotations) {
+ await tx.insert(companyPrItemBids)
+ .values({
+ biddingCompanyId,
+ prItemId: item.prItemId,
+ bidUnitPrice: item.bidUnitPrice.toString(),
+ bidAmount: item.bidAmount.toString(),
+ proposedDeliveryDate: item.proposedDeliveryDate || null,
+ technicalSpecification: item.technicalSpecification || null,
+ currency: 'KRW',
+ isPreQuote: true, // 사전견적 표시
+ submittedAt: new Date(),
+ createdAt: new Date(),
+ updatedAt: new Date()
+ })
+ }
+
+ // 총 금액 계산
+ totalAmount = responseData.prItemQuotations.reduce((sum, item) => sum + item.bidAmount, 0)
+
+ // biddingCompanies에 총 금액 임시 저장 (status는 변경하지 않음)
+ await tx.update(biddingCompanies)
+ .set({
+ preQuoteAmount: totalAmount.toString(),
+ updatedAt: new Date()
+ })
+ .where(eq(biddingCompanies.id, biddingCompanyId))
+ }
+
+ // company_condition_responses 업데이트 (임시저장)
+ const conditionResult = await tx.update(companyConditionResponses)
+ .set({
+ paymentTermsResponse: responseData.paymentTermsResponse || null,
+ taxConditionsResponse: responseData.taxConditionsResponse || null,
+ incotermsResponse: responseData.incotermsResponse || null,
+ proposedContractDeliveryDate: responseData.proposedContractDeliveryDate || null,
+ proposedShippingPort: responseData.proposedShippingPort || null,
+ proposedDestinationPort: responseData.proposedDestinationPort || null,
+ priceAdjustmentResponse: responseData.priceAdjustmentResponse || null,
+ isInitialResponse: responseData.isInitialResponse || null,
+ sparePartResponse: responseData.sparePartResponse || null,
+ additionalProposals: responseData.additionalProposals || null,
+ updatedAt: new Date()
+ })
+ .where(eq(companyConditionResponses.biddingCompanyId, biddingCompanyId))
+ .returning()
+
+ // 연동제 정보 저장 (연동제 적용이 true이고 연동제 정보가 있는 경우)
+ if (responseData.priceAdjustmentResponse && responseData.priceAdjustmentForm && conditionResult.length > 0) {
+ const companyConditionResponseId = conditionResult[0].id
+
+ const priceAdjustmentData = {
+ companyConditionResponsesId: companyConditionResponseId,
+ itemName: responseData.priceAdjustmentForm.itemName,
+ adjustmentReflectionPoint: responseData.priceAdjustmentForm.adjustmentReflectionPoint,
+ majorApplicableRawMaterial: responseData.priceAdjustmentForm.majorApplicableRawMaterial,
+ adjustmentFormula: responseData.priceAdjustmentForm.adjustmentFormula,
+ rawMaterialPriceIndex: responseData.priceAdjustmentForm.rawMaterialPriceIndex,
+ referenceDate: responseData.priceAdjustmentForm.referenceDate as string || null,
+ comparisonDate: responseData.priceAdjustmentForm.comparisonDate as string || null,
+ adjustmentRatio: responseData.priceAdjustmentForm.adjustmentRatio || null,
+ notes: responseData.priceAdjustmentForm.notes,
+ adjustmentConditions: responseData.priceAdjustmentForm.adjustmentConditions,
+ majorNonApplicableRawMaterial: responseData.priceAdjustmentForm.majorNonApplicableRawMaterial,
+ adjustmentPeriod: responseData.priceAdjustmentForm.adjustmentPeriod,
+ contractorWriter: responseData.priceAdjustmentForm.contractorWriter,
+ adjustmentDate: responseData.priceAdjustmentForm.adjustmentDate as string || null,
+ nonApplicableReason: responseData.priceAdjustmentForm.nonApplicableReason,
+ } as any
+
+ // 기존 연동제 정보가 있는지 확인
+ const existingPriceAdjustment = await tx
+ .select()
+ .from(priceAdjustmentForms)
+ .where(eq(priceAdjustmentForms.companyConditionResponsesId, companyConditionResponseId))
+ .limit(1)
+
+ if (existingPriceAdjustment.length > 0) {
+ // 업데이트
+ await tx
+ .update(priceAdjustmentForms)
+ .set(priceAdjustmentData)
+ .where(eq(priceAdjustmentForms.companyConditionResponsesId, companyConditionResponseId))
+ } else {
+ // 새로 생성
+ await tx.insert(priceAdjustmentForms).values(priceAdjustmentData)
+ }
+ }
+ })
+
+ return {
+ success: true,
+ message: '임시저장이 완료되었습니다.',
+ totalAmount
+ }
+ } catch (error) {
+ console.error('Failed to save pre-quote draft:', error)
+ return {
+ success: false,
+ error: error instanceof Error ? error.message : '임시저장에 실패했습니다.'
+ }
+ }
+}
+
+// 견적 문서 업로드
+export async function uploadPreQuoteDocument(
+ biddingId: number,
+ companyId: number,
+ file: File,
+ userId: string
+) {
+ try {
+ const userName = await getUserNameById(userId)
+ // 파일 저장
+ const saveResult = await saveFile({
+ file,
+ directory: `bidding/${biddingId}/quotations`,
+ originalName: file.name,
+ userId
+ })
+
+ if (!saveResult.success) {
+ return {
+ success: false,
+ error: saveResult.error || '파일 저장에 실패했습니다.'
+ }
+ }
+
+ // 데이터베이스에 문서 정보 저장
+ const result = await db.insert(biddingDocuments)
+ .values({
+ biddingId,
+ companyId,
+ documentType: 'other', // 견적서 타입
+ fileName: saveResult.fileName!,
+ originalFileName: file.name,
+ fileSize: file.size,
+ mimeType: file.type,
+ filePath: saveResult.publicPath!, // publicPath 사용 (웹 접근 가능한 경로)
+ title: `견적서 - ${file.name}`,
+ description: '협력업체 제출 견적서',
+ isPublic: false,
+ isRequired: false,
+ uploadedBy: userName,
+ uploadedAt: new Date()
+ })
+ .returning()
+
+ return {
+ success: true,
+ message: '견적서가 성공적으로 업로드되었습니다.',
+ documentId: result[0].id
+ }
+ } catch (error) {
+ console.error('Failed to upload pre-quote document:', error)
+ return {
+ success: false,
+ error: error instanceof Error ? error.message : '견적서 업로드에 실패했습니다.'
+ }
+ }
+}
+
+// 업로드된 견적 문서 목록 조회
+export async function getPreQuoteDocuments(biddingId: number, companyId: number) {
+ try {
+ const documents = await db
+ .select({
+ id: biddingDocuments.id,
+ fileName: biddingDocuments.fileName,
+ originalFileName: biddingDocuments.originalFileName,
+ fileSize: biddingDocuments.fileSize,
+ filePath: biddingDocuments.filePath,
+ title: biddingDocuments.title,
+ description: biddingDocuments.description,
+ uploadedAt: biddingDocuments.uploadedAt,
+ uploadedBy: biddingDocuments.uploadedBy
+ })
+ .from(biddingDocuments)
+ .where(
+ and(
+ eq(biddingDocuments.biddingId, biddingId),
+ eq(biddingDocuments.companyId, companyId),
+ )
+ )
+
+ return documents
+ } catch (error) {
+ console.error('Failed to get pre-quote documents:', error)
+ return []
+ }
+ }
+
+// 저장된 품목별 견적 조회 (임시저장/기존 데이터 불러오기용)
+export async function getSavedPrItemQuotations(biddingCompanyId: number) {
+ try {
+ const savedQuotations = await db
+ .select({
+ prItemId: companyPrItemBids.prItemId,
+ bidUnitPrice: companyPrItemBids.bidUnitPrice,
+ bidAmount: companyPrItemBids.bidAmount,
+ proposedDeliveryDate: companyPrItemBids.proposedDeliveryDate,
+ technicalSpecification: companyPrItemBids.technicalSpecification,
+ currency: companyPrItemBids.currency
+ })
+ .from(companyPrItemBids)
+ .where(
+ and(
+ eq(companyPrItemBids.biddingCompanyId, biddingCompanyId),
+ eq(companyPrItemBids.isPreQuote, true)
+ )
+ )
+
+ // Decimal 타입을 number로 변환
+ return savedQuotations.map(item => ({
+ prItemId: item.prItemId,
+ bidUnitPrice: parseFloat(item.bidUnitPrice || '0'),
+ bidAmount: parseFloat(item.bidAmount || '0'),
+ proposedDeliveryDate: item.proposedDeliveryDate,
+ technicalSpecification: item.technicalSpecification,
+ currency: item.currency
+ }))
+ } catch (error) {
+ console.error('Failed to get saved PR item quotations:', error)
+ return []
+ }
+ }
+
+// 견적 문서 정보 조회 (다운로드용)
+export async function getPreQuoteDocumentForDownload(
+ documentId: number,
+ biddingId: number,
+ companyId: number
+) {
+ try {
+ const document = await db
+ .select({
+ fileName: biddingDocuments.fileName,
+ originalFileName: biddingDocuments.originalFileName,
+ filePath: biddingDocuments.filePath
+ })
+ .from(biddingDocuments)
+ .where(
+ and(
+ eq(biddingDocuments.id, documentId),
+ eq(biddingDocuments.biddingId, biddingId),
+ eq(biddingDocuments.companyId, companyId),
+ eq(biddingDocuments.documentType, 'other')
+ )
+ )
+ .limit(1)
+
+ if (document.length === 0) {
+ return {
+ success: false,
+ error: '문서를 찾을 수 없습니다.'
+ }
+ }
+
+ return {
+ success: true,
+ document: document[0]
+ }
+ } catch (error) {
+ console.error('Failed to get pre-quote document:', error)
+ return {
+ success: false,
+ error: '문서 정보 조회에 실패했습니다.'
+ }
+ }
+}
+
+// 견적 문서 삭제
+export async function deletePreQuoteDocument(
+ documentId: number,
+ biddingId: number,
+ companyId: number,
+ userId: string
+) {
+ try {
+ // 문서 존재 여부 및 권한 확인
+ const document = await db
+ .select({
+ id: biddingDocuments.id,
+ fileName: biddingDocuments.fileName,
+ filePath: biddingDocuments.filePath,
+ uploadedBy: biddingDocuments.uploadedBy
+ })
+ .from(biddingDocuments)
+ .where(
+ and(
+ eq(biddingDocuments.id, documentId),
+ eq(biddingDocuments.biddingId, biddingId),
+ eq(biddingDocuments.companyId, companyId),
+ eq(biddingDocuments.documentType, 'other')
+ )
+ )
+ .limit(1)
+
+ if (document.length === 0) {
+ return {
+ success: false,
+ error: '문서를 찾을 수 없습니다.'
+ }
+ }
+
+ const doc = document[0]
+
+ // 데이터베이스에서 문서 정보 삭제
+ await db
+ .delete(biddingDocuments)
+ .where(eq(biddingDocuments.id, documentId))
+
+ return {
+ success: true,
+ message: '문서가 성공적으로 삭제되었습니다.'
+ }
+ } catch (error) {
+ console.error('Failed to delete pre-quote document:', error)
+ return {
+ success: false,
+ error: '문서 삭제에 실패했습니다.'
+ }
+ }
+ }
+
+// 기본계약 발송 (서버 액션)
+export async function sendBiddingBasicContracts(
+ biddingId: number,
+ vendorData: Array<{
+ vendorId: number
+ vendorName: string
+ vendorCode?: string
+ vendorCountry?: string
+ selectedMainEmail: string
+ additionalEmails: string[]
+ customEmails?: Array<{ email: string; name?: string }>
+ contractRequirements: {
+ ndaYn: boolean
+ generalGtcYn: boolean
+ projectGtcYn: boolean
+ agreementYn: boolean
+ }
+ biddingCompanyId: number
+ biddingId: number
+ hasExistingContracts?: boolean
+ }>,
+ generatedPdfs: Array<{
+ key: string
+ buffer: number[]
+ fileName: string
+ }>,
+ message?: string
+) {
+ try {
+ console.log("sendBiddingBasicContracts called with:", { biddingId, vendorData: vendorData.map(v => ({ vendorId: v.vendorId, biddingCompanyId: v.biddingCompanyId, biddingId: v.biddingId })) });
+
+ // 현재 사용자 정보 조회 (임시로 첫 번째 사용자 사용)
+ const [currentUser] = await db.select().from(users).limit(1)
+
+ if (!currentUser) {
+ throw new Error("사용자 정보를 찾을 수 없습니다.")
+ }
+
+ const results = []
+ const savedContracts = []
+
+ // 트랜잭션 시작
+ const contractsDir = path.join(process.cwd(), `${process.env.NAS_PATH}`, "contracts", "generated");
+ await mkdir(contractsDir, { recursive: true });
+
+ const result = await db.transaction(async (tx) => {
+ // 각 벤더별로 기본계약 생성 및 이메일 발송
+ for (const vendor of vendorData) {
+ // 기존 계약 확인 (biddingCompanyId 기준)
+ if (vendor.hasExistingContracts) {
+ console.log(`벤더 ${vendor.vendorName}는 이미 계약이 체결되어 있어 건너뜁니다.`)
+ continue
+ }
+
+ // 벤더 정보 조회
+ const [vendorInfo] = await tx
+ .select()
+ .from(vendors)
+ .where(eq(vendors.id, vendor.vendorId))
+ .limit(1)
+
+ if (!vendorInfo) {
+ console.error(`벤더 정보를 찾을 수 없습니다: ${vendor.vendorId}`)
+ continue
+ }
+
+ // biddingCompany 정보 조회 (biddingCompanyId를 직접 사용)
+ console.log(`Looking for biddingCompany with id=${vendor.biddingCompanyId}`)
+ let [biddingCompanyInfo] = await tx
+ .select()
+ .from(biddingCompanies)
+ .where(eq(biddingCompanies.id, vendor.biddingCompanyId))
+ .limit(1)
+
+ console.log(`Found biddingCompanyInfo:`, biddingCompanyInfo)
+ if (!biddingCompanyInfo) {
+ console.error(`입찰 회사 정보를 찾을 수 없습니다: biddingCompanyId=${vendor.biddingCompanyId}`)
+ // fallback: biddingId와 vendorId로 찾기 시도
+ console.log(`Fallback: Looking for biddingCompany with biddingId=${biddingId}, companyId=${vendor.vendorId}`)
+ const [fallbackCompanyInfo] = await tx
+ .select()
+ .from(biddingCompanies)
+ .where(and(
+ eq(biddingCompanies.biddingId, biddingId),
+ eq(biddingCompanies.companyId, vendor.vendorId)
+ ))
+ .limit(1)
+ console.log(`Fallback found biddingCompanyInfo:`, fallbackCompanyInfo)
+ if (fallbackCompanyInfo) {
+ console.log(`Using fallback biddingCompanyInfo`)
+ biddingCompanyInfo = fallbackCompanyInfo
+ } else {
+ console.log(`Available biddingCompanies for biddingId=${biddingId}:`, await tx.select().from(biddingCompanies).where(eq(biddingCompanies.biddingId, biddingId)).limit(10))
+ continue
+ }
+ }
+
+ // 계약 요구사항에 따라 계약서 생성
+ const contractTypes: Array<{ type: string; templateName: string }> = []
+ if (vendor.contractRequirements?.ndaYn) contractTypes.push({ type: 'NDA', templateName: '비밀' })
+ if (vendor.contractRequirements?.generalGtcYn) contractTypes.push({ type: 'General_GTC', templateName: 'General GTC' })
+ if (vendor.contractRequirements?.projectGtcYn) contractTypes.push({ type: 'Project_GTC', templateName: '기술' })
+ if (vendor.contractRequirements?.agreementYn) contractTypes.push({ type: '기술자료', templateName: '기술자료' })
+
+ // contractRequirements가 없거나 빈 객체인 경우 빈 배열로 처리
+ if (!vendor.contractRequirements || Object.keys(vendor.contractRequirements).length === 0) {
+ console.log(`Skipping vendor ${vendor.vendorId} - no contract requirements specified`)
+ continue
+ }
+
+ console.log("contractTypes", contractTypes)
+ for (const contractType of contractTypes) {
+ // PDF 데이터 찾기 (include를 사용하여 유연하게 찾기)
+ console.log("generatedPdfs", generatedPdfs.map(pdf => pdf.key))
+ const pdfData = generatedPdfs.find((pdf: any) =>
+ pdf.key.includes(`${vendor.vendorId}_`) &&
+ pdf.key.includes(`_${contractType.templateName}`)
+ )
+ console.log("pdfData", pdfData, "for contractType", contractType)
+ if (!pdfData) {
+ console.error(`PDF 데이터를 찾을 수 없습니다: vendorId=${vendor.vendorId}, templateName=${contractType.templateName}`)
+ continue
+ }
+
+ // 파일 저장 (rfq-last 방식)
+ const fileName = `${contractType.type}_${vendor.vendorCode || vendor.vendorId}_${vendor.biddingCompanyId}_${Date.now()}.pdf`
+ const filePath = path.join(contractsDir, fileName);
+
+ await writeFile(filePath, Buffer.from(pdfData.buffer));
+
+ // 템플릿 정보 조회 (rfq-last 방식)
+ const [template] = await db
+ .select()
+ .from(basicContractTemplates)
+ .where(
+ and(
+ ilike(basicContractTemplates.templateName, `%${contractType.templateName}%`),
+ eq(basicContractTemplates.status, "ACTIVE")
+ )
+ )
+ .limit(1);
+
+ console.log("템플릿", contractType.templateName, template);
+
+ // 기존 계약이 있는지 확인 (rfq-last 방식)
+ const [existingContract] = await tx
+ .select()
+ .from(basicContract)
+ .where(
+ and(
+ eq(basicContract.templateId, template?.id),
+ eq(basicContract.vendorId, vendor.vendorId),
+ eq(basicContract.biddingCompanyId, biddingCompanyInfo.id)
+ )
+ )
+ .limit(1);
+
+ let contractRecord;
+
+ if (existingContract) {
+ // 기존 계약이 있으면 업데이트
+ [contractRecord] = await tx
+ .update(basicContract)
+ .set({
+ requestedBy: currentUser.id,
+ status: "PENDING", // 재발송 상태
+ fileName: fileName,
+ filePath: `/contracts/generated/${fileName}`,
+ deadline: new Date(Date.now() + 10 * 24 * 60 * 60 * 1000),
+ updatedAt: new Date(),
+ })
+ .where(eq(basicContract.id, existingContract.id))
+ .returning();
+
+ console.log("기존 계약 업데이트:", contractRecord.id);
+ } else {
+ // 새 계약 생성
+ [contractRecord] = await tx
+ .insert(basicContract)
+ .values({
+ templateId: template?.id || null,
+ vendorId: vendor.vendorId,
+ biddingCompanyId: biddingCompanyInfo.id,
+ rfqCompanyId: null,
+ generalContractId: null,
+ requestedBy: currentUser.id,
+ status: 'PENDING',
+ fileName: fileName,
+ filePath: `/contracts/generated/${fileName}`,
+ deadline: new Date(Date.now() + 10 * 24 * 60 * 60 * 1000), // 10일 후
+ createdAt: new Date(),
+ updatedAt: new Date(),
+ })
+ .returning();
+
+ console.log("새 계약 생성:", contractRecord.id);
+ }
+
+ results.push({
+ vendorId: vendor.vendorId,
+ vendorName: vendor.vendorName,
+ contractId: contractRecord.id,
+ contractType: contractType.type,
+ fileName: fileName,
+ filePath: `/contracts/generated/${fileName}`,
+ })
+
+ // savedContracts에 추가 (rfq-last 방식)
+ // savedContracts.push({
+ // vendorId: vendor.vendorId,
+ // vendorName: vendor.vendorName,
+ // templateName: contractType.templateName,
+ // contractId: contractRecord.id,
+ // fileName: fileName,
+ // isUpdated: !!existingContract, // 업데이트 여부 표시
+ // })
+ }
+
+ // 이메일 발송 (선택사항)
+ if (vendor.selectedMainEmail) {
+ try {
+ await sendEmail({
+ to: vendor.selectedMainEmail,
+ template: 'basic-contract-notification',
+ context: {
+ vendorName: vendor.vendorName,
+ biddingId: biddingId,
+ contractCount: contractTypes.length,
+ deadline: new Date(Date.now() + 10 * 24 * 60 * 60 * 1000).toLocaleDateString('ko-KR'),
+ loginUrl: `${process.env.NEXT_PUBLIC_APP_URL}/partners/bid/${biddingId}`,
+ message: message || '',
+ currentYear: new Date().getFullYear(),
+ language: 'ko'
+ }
+ })
+ } catch (emailError) {
+ console.error(`이메일 발송 실패 (${vendor.selectedMainEmail}):`, emailError)
+ // 이메일 발송 실패해도 계약 생성은 유지
+ }
+ }
+ }
+
+ return {
+ success: true,
+ message: `${results.length}개의 기본계약이 생성되었습니다.`,
+ results,
+ savedContracts,
+ totalContracts: savedContracts.length,
+ }
+ })
+
+ return result
+
+ } catch (error) {
+ console.error('기본계약 발송 실패:', error)
+ throw new Error(
+ error instanceof Error
+ ? error.message
+ : '기본계약 발송 중 오류가 발생했습니다.'
+ )
+ }
+}
+
+// 기존 기본계약 조회 (서버 액션)
+export async function getExistingBasicContractsForBidding(biddingId: number) {
+ try {
+ // 해당 biddingId에 속한 biddingCompany들의 기존 기본계약 조회
+ const existingContracts = await db
+ .select({
+ id: basicContract.id,
+ vendorId: basicContract.vendorId,
+ biddingCompanyId: basicContract.biddingCompanyId,
+ biddingId: biddingCompanies.biddingId,
+ templateId: basicContract.templateId,
+ status: basicContract.status,
+ createdAt: basicContract.createdAt,
+ })
+ .from(basicContract)
+ .leftJoin(biddingCompanies, eq(basicContract.biddingCompanyId, biddingCompanies.id))
+ .where(
+ and(
+ eq(biddingCompanies.biddingId, biddingId),
+ )
+ )
+
+ return {
+ success: true,
+ contracts: existingContracts
+ }
+
+ } catch (error) {
+ console.error('기존 계약 조회 실패:', error)
+ return {
+ success: false,
+ error: '기존 계약 조회에 실패했습니다.'
+ }
+ }
+}
+
+// 선정된 업체들 조회 (서버 액션)
+export async function getSelectedVendorsForBidding(biddingId: number) {
+ try {
+ const selectedCompanies = await db
+ .select({
+ id: biddingCompanies.id,
+ companyId: biddingCompanies.companyId,
+ companyName: vendors.vendorName,
+ companyCode: vendors.vendorCode,
+ companyEmail: vendors.email,
+ companyCountry: vendors.country,
+ contactPerson: biddingCompanies.contactPerson,
+ contactEmail: biddingCompanies.contactEmail,
+ biddingId: biddingCompanies.biddingId,
+ })
+ .from(biddingCompanies)
+ .leftJoin(vendors, eq(biddingCompanies.companyId, vendors.id))
+ .where(and(
+ eq(biddingCompanies.biddingId, biddingId),
+ eq(biddingCompanies.isPreQuoteSelected, true)
+ ))
+
+ return {
+ success: true,
+ vendors: selectedCompanies.map(company => ({
+ vendorId: company.companyId, // 실제 vendor ID
+ vendorName: company.companyName || '',
+ vendorCode: company.companyCode,
+ vendorEmail: company.companyEmail,
+ vendorCountry: company.companyCountry || '대한민국',
+ contactPerson: company.contactPerson,
+ contactEmail: company.contactEmail,
+ biddingCompanyId: company.id, // biddingCompany ID
+ biddingId: company.biddingId,
+ ndaYn: true, // 모든 계약 타입을 활성화 (필요에 따라 조정)
+ generalGtcYn: true,
+ projectGtcYn: true,
+ agreementYn: true
+ }))
+ }
+ } catch (error) {
+ console.error('선정된 업체 조회 실패:', error)
+ return {
+ success: false,
+ error: '선정된 업체 조회에 실패했습니다.',
+ vendors: []
+ }
+ }
} \ No newline at end of file
diff --git a/lib/bidding/pre-quote/table/bidding-pre-quote-attachments-dialog.tsx b/lib/bidding/pre-quote/table/bidding-pre-quote-attachments-dialog.tsx
deleted file mode 100644
index cfa629e3..00000000
--- a/lib/bidding/pre-quote/table/bidding-pre-quote-attachments-dialog.tsx
+++ /dev/null
@@ -1,224 +0,0 @@
-'use client'
-
-import * as React from 'react'
-import {
- Dialog,
- DialogContent,
- DialogDescription,
- DialogHeader,
- DialogTitle,
-} from '@/components/ui/dialog'
-import { Button } from '@/components/ui/button'
-import { Badge } from '@/components/ui/badge'
-import {
- Table,
- TableBody,
- TableCell,
- TableHead,
- TableHeader,
- TableRow,
-} from '@/components/ui/table'
-import {
- FileText,
- Download,
- User,
- Calendar
-} from 'lucide-react'
-import { useToast } from '@/hooks/use-toast'
-import { useTransition } from 'react'
-import { getPreQuoteDocuments, getPreQuoteDocumentForDownload } from '../service'
-import { downloadFile } from '@/lib/file-download'
-
-interface UploadedDocument {
- id: number
- fileName: string
- originalFileName: string
- fileSize: number | null
- filePath: string
- title: string | null
- description: string | null
- uploadedAt: string
- uploadedBy: string
-}
-
-interface BiddingPreQuoteAttachmentsDialogProps {
- open: boolean
- onOpenChange: (open: boolean) => void
- biddingId: number
- companyId: number
- companyName: string
-}
-
-export function BiddingPreQuoteAttachmentsDialog({
- open,
- onOpenChange,
- biddingId,
- companyId,
- companyName
-}: BiddingPreQuoteAttachmentsDialogProps) {
- const { toast } = useToast()
- const [isPending, startTransition] = useTransition()
- const [documents, setDocuments] = React.useState<UploadedDocument[]>([])
- const [isLoading, setIsLoading] = React.useState(false)
-
- // 다이얼로그가 열릴 때 첨부파일 목록 로드
- React.useEffect(() => {
- if (open) {
- loadDocuments()
- }
- }, [open, biddingId, companyId])
-
- const loadDocuments = async () => {
- setIsLoading(true)
- try {
- const docs = await getPreQuoteDocuments(biddingId, companyId)
- // Date를 string으로 변환
- const mappedDocs = docs.map(doc => ({
- ...doc,
- uploadedAt: doc.uploadedAt.toString(),
- uploadedBy: doc.uploadedBy || ''
- }))
- setDocuments(mappedDocs)
- } catch (error) {
- console.error('Failed to load documents:', error)
- toast({
- title: '오류',
- description: '첨부파일 목록을 불러오는데 실패했습니다.',
- variant: 'destructive',
- })
- } finally {
- setIsLoading(false)
- }
- }
-
- // 파일 다운로드
- const handleDownload = (document: UploadedDocument) => {
- startTransition(async () => {
- const result = await getPreQuoteDocumentForDownload(document.id, biddingId, companyId)
-
- if (result.success) {
- try {
- await downloadFile(result.document?.filePath, result.document?.originalFileName, {
- showToast: true
- })
- } catch (error) {
- toast({
- title: '다운로드 실패',
- description: '파일 다운로드에 실패했습니다.',
- variant: 'destructive',
- })
- }
- } else {
- toast({
- title: '다운로드 실패',
- description: result.error,
- variant: 'destructive',
- })
- }
- })
- }
-
- // 파일 크기 포맷팅
- const formatFileSize = (bytes: number | null) => {
- if (!bytes) return '-'
- if (bytes === 0) return '0 Bytes'
- const k = 1024
- const sizes = ['Bytes', 'KB', 'MB', 'GB']
- const i = Math.floor(Math.log(bytes) / Math.log(k))
- return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i]
- }
-
- return (
- <Dialog open={open} onOpenChange={onOpenChange}>
- <DialogContent className="max-w-4xl max-h-[80vh] overflow-y-auto">
- <DialogHeader>
- <DialogTitle className="flex items-center gap-2">
- <FileText className="w-5 h-5" />
- <span>협력업체 첨부파일</span>
- <span className="text-sm font-normal text-muted-foreground">
- - {companyName}
- </span>
- </DialogTitle>
- <DialogDescription>
- 협력업체가 제출한 견적 관련 첨부파일 목록입니다.
- </DialogDescription>
- </DialogHeader>
-
- {isLoading ? (
- <div className="flex items-center justify-center py-12">
- <div className="text-center">
- <div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary mx-auto mb-4"></div>
- <p className="text-muted-foreground">첨부파일 목록을 불러오는 중...</p>
- </div>
- </div>
- ) : documents.length > 0 ? (
- <div className="space-y-4">
- <div className="flex items-center justify-between">
- <Badge variant="secondary" className="text-sm">
- 총 {documents.length}개 파일
- </Badge>
- </div>
-
- <Table>
- <TableHeader>
- <TableRow>
- <TableHead>파일명</TableHead>
- <TableHead>크기</TableHead>
- <TableHead>업로드일</TableHead>
- <TableHead>작성자</TableHead>
- <TableHead className="w-24">작업</TableHead>
- </TableRow>
- </TableHeader>
- <TableBody>
- {documents.map((doc) => (
- <TableRow key={doc.id}>
- <TableCell>
- <div className="flex items-center gap-2">
- <FileText className="w-4 h-4 text-gray-500" />
- <span className="truncate max-w-48" title={doc.originalFileName}>
- {doc.originalFileName}
- </span>
- </div>
- </TableCell>
- <TableCell className="text-sm text-gray-500">
- {formatFileSize(doc.fileSize)}
- </TableCell>
- <TableCell className="text-sm text-gray-500">
- <div className="flex items-center gap-1">
- <Calendar className="w-3 h-3" />
- {new Date(doc.uploadedAt).toLocaleDateString('ko-KR')}
- </div>
- </TableCell>
- <TableCell className="text-sm text-gray-500">
- <div className="flex items-center gap-1">
- <User className="w-3 h-3" />
- {doc.uploadedBy}
- </div>
- </TableCell>
- <TableCell>
- <Button
- variant="outline"
- size="sm"
- onClick={() => handleDownload(doc)}
- disabled={isPending}
- title="다운로드"
- >
- <Download className="w-3 h-3" />
- </Button>
- </TableCell>
- </TableRow>
- ))}
- </TableBody>
- </Table>
- </div>
- ) : (
- <div className="text-center py-12 text-gray-500">
- <FileText className="w-12 h-12 mx-auto mb-4 opacity-50" />
- <p className="text-lg font-medium mb-2">첨부파일이 없습니다</p>
- <p className="text-sm">협력업체가 아직 첨부파일을 업로드하지 않았습니다.</p>
- </div>
- )}
- </DialogContent>
- </Dialog>
- )
-}
diff --git a/lib/bidding/pre-quote/table/bidding-pre-quote-content.tsx b/lib/bidding/pre-quote/table/bidding-pre-quote-content.tsx
deleted file mode 100644
index 91b80bd3..00000000
--- a/lib/bidding/pre-quote/table/bidding-pre-quote-content.tsx
+++ /dev/null
@@ -1,51 +0,0 @@
-'use client'
-
-import * as React from 'react'
-import { Bidding } from '@/db/schema'
-import { QuotationDetails } from '@/lib/bidding/detail/service'
-import { getBiddingCompanies } from '../service'
-
-import { BiddingPreQuoteVendorTableContent } from './bidding-pre-quote-vendor-table'
-
-interface BiddingPreQuoteContentProps {
- bidding: Bidding
- quotationDetails: QuotationDetails | null
- biddingCompanies: any[]
- prItems: any[]
-}
-
-export function BiddingPreQuoteContent({
- bidding,
- quotationDetails,
- biddingCompanies: initialBiddingCompanies,
- prItems
-}: BiddingPreQuoteContentProps) {
- const [biddingCompanies, setBiddingCompanies] = React.useState(initialBiddingCompanies)
- const [refreshTrigger, setRefreshTrigger] = React.useState(0)
-
- const handleRefresh = React.useCallback(async () => {
- try {
- const result = await getBiddingCompanies(bidding.id)
- if (result.success && result.data) {
- setBiddingCompanies(result.data)
- }
- setRefreshTrigger(prev => prev + 1)
- } catch (error) {
- console.error('Failed to refresh bidding companies:', error)
- }
- }, [bidding.id])
-
- return (
- <div className="space-y-6">
- <BiddingPreQuoteVendorTableContent
- biddingId={bidding.id}
- bidding={bidding}
- biddingCompanies={biddingCompanies}
- onRefresh={handleRefresh}
- onOpenItemsDialog={() => {}}
- onOpenTargetPriceDialog={() => {}}
- onOpenSelectionReasonDialog={() => {}}
- />
- </div>
- )
-}
diff --git a/lib/bidding/pre-quote/table/bidding-pre-quote-invitation-dialog.tsx b/lib/bidding/pre-quote/table/bidding-pre-quote-invitation-dialog.tsx
deleted file mode 100644
index 3205df08..00000000
--- a/lib/bidding/pre-quote/table/bidding-pre-quote-invitation-dialog.tsx
+++ /dev/null
@@ -1,770 +0,0 @@
-'use client'
-
-import * as React from 'react'
-import { Button } from '@/components/ui/button'
-import { Checkbox } from '@/components/ui/checkbox'
-import { Input } from '@/components/ui/input'
-import { Label } from '@/components/ui/label'
-import {
- Dialog,
- DialogContent,
- DialogDescription,
- DialogFooter,
- DialogHeader,
- DialogTitle,
-} from '@/components/ui/dialog'
-import { Badge } from '@/components/ui/badge'
-import { BiddingCompany } from './bidding-pre-quote-vendor-columns'
-import { sendPreQuoteInvitations, sendBiddingBasicContracts, getExistingBasicContractsForBidding } from '../service'
-import { getActiveContractTemplates } from '../../service'
-import { useToast } from '@/hooks/use-toast'
-import { useTransition } from 'react'
-import { Mail, Building2, Calendar, FileText, CheckCircle, Info, RefreshCw } from 'lucide-react'
-import { Progress } from '@/components/ui/progress'
-import { Separator } from '@/components/ui/separator'
-import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'
-import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
-import { cn } from '@/lib/utils'
-
-interface BiddingPreQuoteInvitationDialogProps {
- open: boolean
- onOpenChange: (open: boolean) => void
- companies: BiddingCompany[]
- biddingId: number
- biddingTitle: string
- projectName?: string
- onSuccess: () => void
-}
-
-interface BasicContractTemplate {
- id: number
- templateName: string
- revision: number
- status: string
- filePath: string | null
- validityPeriod: number | null
- legalReviewRequired: boolean
- createdAt: Date | null
-}
-
-interface SelectedContract {
- templateId: number
- templateName: string
- contractType: string // templateName을 contractType으로 사용
- checked: boolean
-}
-
-// PDF 생성 유틸리티 함수
-const generateBasicContractPdf = async (
- template: BasicContractTemplate,
- vendorId: number
-): Promise<{ buffer: number[]; fileName: string }> => {
- try {
- // 1. 템플릿 데이터 준비 (서버 API 호출)
- const prepareResponse = await fetch("/api/contracts/prepare-template", {
- method: "POST",
- headers: { "Content-Type": "application/json" },
- body: JSON.stringify({
- templateName: template.templateName,
- vendorId,
- }),
- });
-
- if (!prepareResponse.ok) {
- throw new Error("템플릿 준비 실패");
- }
-
- const { template: preparedTemplate, templateData } = await prepareResponse.json();
-
- // 2. 템플릿 파일 다운로드
- const templateResponse = await fetch("/api/contracts/get-template", {
- method: "POST",
- headers: { "Content-Type": "application/json" },
- body: JSON.stringify({ templatePath: preparedTemplate.filePath }),
- });
-
- const templateBlob = await templateResponse.blob();
- const templateFile = new window.File([templateBlob], "template.docx", {
- type: "application/vnd.openxmlformats-officedocument.wordprocessingml.document"
- });
-
- // 3. PDFTron WebViewer로 PDF 변환
- const { default: WebViewer } = await import("@pdftron/webviewer");
-
- const tempDiv = document.createElement('div');
- tempDiv.style.display = 'none';
- document.body.appendChild(tempDiv);
-
- try {
- const instance = await WebViewer(
- {
- path: "/pdftronWeb",
- licenseKey: process.env.NEXT_PUBLIC_PDFTRON_WEBVIEW_KEY,
- fullAPI: true,
- },
- tempDiv
- );
-
- const { Core } = instance;
- const { createDocument } = Core;
-
- const templateDoc = await createDocument(templateFile, {
- filename: templateFile.name,
- extension: 'docx',
- });
-
- // 변수 치환 적용
- await templateDoc.applyTemplateValues(templateData);
-
- // PDF 변환
- const fileData = await templateDoc.getFileData();
- const pdfBuffer = await Core.officeToPDFBuffer(fileData, { extension: 'docx' });
-
- const fileName = `${template.templateName}_${Date.now()}.pdf`;
-
- return {
- buffer: Array.from(pdfBuffer), // Uint8Array를 일반 배열로 변환
- fileName
- };
-
- } finally {
- if (tempDiv.parentNode) {
- document.body.removeChild(tempDiv);
- }
- }
- } catch (error) {
- console.error(`기본계약 PDF 생성 실패 (${template.templateName}):`, error);
- throw error;
- }
-};
-
-export function BiddingPreQuoteInvitationDialog({
- open,
- onOpenChange,
- companies,
- biddingId,
- biddingTitle,
- projectName,
- onSuccess
-}: BiddingPreQuoteInvitationDialogProps) {
- const { toast } = useToast()
- const [isPending, startTransition] = useTransition()
- const [selectedCompanyIds, setSelectedCompanyIds] = React.useState<number[]>([])
- const [preQuoteDeadline, setPreQuoteDeadline] = React.useState('')
- const [additionalMessage, setAdditionalMessage] = React.useState('')
-
- // 기본계약 관련 상태
- const [existingContracts, setExistingContracts] = React.useState<any[]>([])
- const [isGeneratingPdfs, setIsGeneratingPdfs] = React.useState(false)
- const [pdfGenerationProgress, setPdfGenerationProgress] = React.useState(0)
- const [currentGeneratingContract, setCurrentGeneratingContract] = React.useState('')
-
- // 기본계약서 템플릿 관련 상태
- const [availableTemplates, setAvailableTemplates] = React.useState<BasicContractTemplate[]>([])
- const [selectedContracts, setSelectedContracts] = React.useState<SelectedContract[]>([])
- const [isLoadingTemplates, setIsLoadingTemplates] = React.useState(false)
-
- // 초대 가능한 업체들 (pending 상태인 업체들)
- const invitableCompanies = React.useMemo(() => companies.filter(company =>
- company.invitationStatus === 'pending' && company.companyName
- ), [companies])
-
- // 다이얼로그가 열릴 때 기존 계약 조회 및 템플릿 로드
- React.useEffect(() => {
- if (open) {
- const fetchInitialData = async () => {
- setIsLoadingTemplates(true);
- try {
- const [contractsResult, templatesData] = await Promise.all([
- getExistingBasicContractsForBidding(biddingId),
- getActiveContractTemplates()
- ]);
-
- // 기존 계약 조회 - 서버 액션 사용
- const existingContractsResult = await getExistingBasicContractsForBidding(biddingId);
- setExistingContracts(existingContractsResult.success ? existingContractsResult.contracts || [] : []);
-
- // 템플릿 로드 (4개 타입만 필터링)
- // 4개 템플릿 타입만 필터링: 비밀, General, Project, 기술자료
- const allowedTemplateNames = ['비밀', 'General GTC', '기술', '기술자료'];
- const filteredTemplates = (templatesData.templates || []).filter((template: any) =>
- allowedTemplateNames.some(allowedName =>
- template.templateName.includes(allowedName) ||
- allowedName.includes(template.templateName)
- )
- );
- setAvailableTemplates(filteredTemplates as BasicContractTemplate[]);
- const initialSelected = filteredTemplates.map((template: any) => ({
- templateId: template.id,
- templateName: template.templateName,
- contractType: template.templateName,
- checked: false
- }));
- setSelectedContracts(initialSelected);
-
- } catch (error) {
- console.error('초기 데이터 로드 실패:', error);
- toast({
- title: '오류',
- description: '기본 정보를 불러오는 데 실패했습니다.',
- variant: 'destructive',
- });
- setExistingContracts([]);
- setAvailableTemplates([]);
- setSelectedContracts([]);
- } finally {
- setIsLoadingTemplates(false);
- }
- }
- fetchInitialData();
- }
- }, [open, biddingId, toast]);
-
- const handleSelectAll = (checked: boolean | 'indeterminate') => {
- if (checked) {
- // 기존 계약이 없는 업체만 선택
- const availableCompanies = invitableCompanies.filter(company =>
- !existingContracts.some(ec => ec.vendorId === company.companyId)
- )
- setSelectedCompanyIds(availableCompanies.map(company => company.id))
- } else {
- setSelectedCompanyIds([])
- }
- }
-
- const handleSelectCompany = (companyId: number, checked: boolean) => {
- const company = invitableCompanies.find(c => c.id === companyId)
- const hasExistingContract = company ? existingContracts.some(ec => ec.vendorId === company.companyId) : false
-
- if (hasExistingContract) {
- toast({
- title: '선택 불가',
- description: '이미 기본계약서를 받은 업체는 다시 선택할 수 없습니다.',
- variant: 'default',
- })
- return
- }
-
- if (checked) {
- setSelectedCompanyIds(prev => [...prev, companyId])
- } else {
- setSelectedCompanyIds(prev => prev.filter(id => id !== companyId))
- }
- }
-
- // 기본계약서 선택 토글
- const toggleContractSelection = (templateId: number) => {
- setSelectedContracts(prev =>
- prev.map(contract =>
- contract.templateId === templateId
- ? { ...contract, checked: !contract.checked }
- : contract
- )
- )
- }
-
- // 모든 기본계약서 선택/해제
- const toggleAllContractSelection = (checked: boolean | 'indeterminate') => {
- setSelectedContracts(prev =>
- prev.map(contract => ({ ...contract, checked: !!checked }))
- )
- }
-
- const handleSendInvitations = () => {
- if (selectedCompanyIds.length === 0) {
- toast({
- title: '알림',
- description: '초대를 발송할 업체를 선택해주세요.',
- variant: 'default',
- })
- return
- }
-
- const selectedContractTemplates = selectedContracts.filter(c => c.checked);
- const companiesForContracts = invitableCompanies.filter(company => selectedCompanyIds.includes(company.id));
-
- const vendorsToGenerateContracts = companiesForContracts.filter(company =>
- !existingContracts.some(ec =>
- ec.vendorId === company.companyId && ec.biddingCompanyId === company.id
- )
- );
-
- startTransition(async () => {
- try {
- // 1. 사전견적 초대 발송
- const invitationResponse = await sendPreQuoteInvitations(
- selectedCompanyIds,
- preQuoteDeadline || undefined
- )
-
- if (!invitationResponse.success) {
- toast({
- title: '초대 발송 실패',
- description: invitationResponse.error,
- variant: 'destructive',
- })
- return
- }
-
- // 2. 기본계약 발송 (선택된 템플릿과 업체가 있는 경우)
- let contractResponse: Awaited<ReturnType<typeof sendBiddingBasicContracts>> | null = null
- if (selectedContractTemplates.length > 0 && selectedCompanyIds.length > 0) {
- setIsGeneratingPdfs(true)
- setPdfGenerationProgress(0)
-
- const generatedPdfsMap = new Map<string, { buffer: number[], fileName: string }>()
-
- let generatedCount = 0;
- for (const vendor of vendorsToGenerateContracts) {
- for (const contract of selectedContractTemplates) {
- setCurrentGeneratingContract(`${vendor.companyName} - ${contract.templateName}`);
- const templateDetails = availableTemplates.find(t => t.id === contract.templateId);
-
- if (templateDetails) {
- const pdfData = await generateBasicContractPdf(templateDetails, vendor.companyId);
- // sendBiddingBasicContracts와 동일한 키 형식 사용
- let contractType = '';
- if (contract.templateName.includes('비밀')) {
- contractType = 'NDA';
- } else if (contract.templateName.includes('General GTC')) {
- contractType = 'General_GTC';
- } else if (contract.templateName.includes('기술') && !contract.templateName.includes('기술자료')) {
- contractType = 'Project_GTC';
- } else if (contract.templateName.includes('기술자료')) {
- contractType = '기술자료';
- }
- const key = `${vendor.companyId}_${contractType}_${contract.templateName}`;
- generatedPdfsMap.set(key, pdfData);
- }
- }
- generatedCount++;
- setPdfGenerationProgress((generatedCount / vendorsToGenerateContracts.length) * 100);
- }
-
- setIsGeneratingPdfs(false);
-
- const vendorData = companiesForContracts.map(company => {
- // 선택된 템플릿에 따라 contractRequirements 동적으로 설정
- const contractRequirements = {
- ndaYn: selectedContractTemplates.some(c => c.templateName.includes('비밀')),
- generalGtcYn: selectedContractTemplates.some(c => c.templateName.includes('General GTC')),
- projectGtcYn: selectedContractTemplates.some(c => c.templateName.includes('기술') && !c.templateName.includes('기술자료')),
- agreementYn: selectedContractTemplates.some(c => c.templateName.includes('기술자료'))
- };
-
- return {
- vendorId: company.companyId,
- vendorName: company.companyName || '',
- vendorCode: company.companyCode,
- vendorCountry: '대한민국',
- selectedMainEmail: company.contactEmail || '',
- contactPerson: company.contactPerson,
- contactEmail: company.contactEmail,
- biddingCompanyId: company.id,
- biddingId: biddingId,
- hasExistingContracts: existingContracts.some(ec =>
- ec.vendorId === company.companyId && ec.biddingCompanyId === company.id
- ),
- contractRequirements,
- additionalEmails: [],
- customEmails: []
- };
- });
-
- const pdfsArray = Array.from(generatedPdfsMap.entries()).map(([key, data]) => ({
- key,
- buffer: data.buffer,
- fileName: data.fileName,
- }));
-
- console.log("Calling sendBiddingBasicContracts with biddingId:", biddingId);
- console.log("vendorData:", vendorData.map(v => ({ vendorId: v.vendorId, biddingCompanyId: v.biddingCompanyId, biddingId: v.biddingId })));
-
- contractResponse = await sendBiddingBasicContracts(
- biddingId,
- vendorData,
- pdfsArray,
- additionalMessage
- );
- }
-
- let successMessage = '사전견적 초대가 성공적으로 발송되었습니다.';
- if (contractResponse && contractResponse.success) {
- successMessage += `\n${contractResponse.message}`;
- }
-
- toast({
- title: '성공',
- description: successMessage,
- })
-
- // 상태 초기화
- setSelectedCompanyIds([]);
- setPreQuoteDeadline('');
- setAdditionalMessage('');
- setExistingContracts([]);
- setIsGeneratingPdfs(false);
- setPdfGenerationProgress(0);
- setCurrentGeneratingContract('');
- setSelectedContracts(prev => prev.map(c => ({ ...c, checked: false })));
-
- onOpenChange(false);
- onSuccess();
-
- } catch (error) {
- console.error('발송 실패:', error);
- toast({
- title: '오류',
- description: '발송 중 오류가 발생했습니다. 잠시 후 다시 시도해 주세요.',
- variant: 'destructive',
- });
- setIsGeneratingPdfs(false);
- }
- })
- }
-
- const handleOpenChange = (open: boolean) => {
- onOpenChange(open)
- if (!open) {
- setSelectedCompanyIds([])
- setPreQuoteDeadline('')
- setAdditionalMessage('')
- setExistingContracts([])
- setIsGeneratingPdfs(false)
- setPdfGenerationProgress(0)
- setCurrentGeneratingContract('')
- setSelectedContracts([])
- }
- }
-
- const selectedContractCount = selectedContracts.filter(c => c.checked).length;
- const selectedCompanyCount = selectedCompanyIds.length;
- const companiesToReceiveContracts = invitableCompanies.filter(company => selectedCompanyIds.includes(company.id));
-
- // 기존 계약이 없는 업체들만 계산
- const availableCompanies = invitableCompanies.filter(company =>
- !existingContracts.some(ec => ec.vendorId === company.companyId)
- );
- const selectedAvailableCompanyCount = selectedCompanyIds.filter(id =>
- availableCompanies.some(company => company.id === id)
- ).length;
-
- // 선택된 업체들 중 기존 계약이 있는 업체들
- const selectedCompaniesWithExistingContracts = invitableCompanies.filter(company =>
- selectedCompanyIds.includes(company.id) &&
- existingContracts.some(ec => ec.vendorId === company.companyId)
- );
-
- return (
- <Dialog open={open} onOpenChange={handleOpenChange}>
- <DialogContent className="sm:max-w-[800px] max-h-[90vh] flex flex-col">
- <DialogHeader>
- <DialogTitle className="flex items-center gap-2">
- <Mail className="w-5 h-5" />
- 사전견적 초대 및 기본계약 발송
- </DialogTitle>
- <DialogDescription>
- 선택한 업체들에게 사전견적 요청과 기본계약서를 발송합니다.
- </DialogDescription>
- </DialogHeader>
-
- <div className="flex-1 overflow-y-auto px-1" style={{ maxHeight: 'calc(70vh - 200px)' }}>
- <div className="space-y-6 pr-4">
- {/* 견적 마감일 설정 */}
- <div className="mb-6 p-4 border rounded-lg bg-muted/30">
- <Label htmlFor="preQuoteDeadline" className="text-sm font-medium mb-2 flex items-center gap-2">
- <Calendar className="w-4 h-4" />
- 견적 마감일
- </Label>
- <Input
- id="preQuoteDeadline"
- type="datetime-local"
- value={preQuoteDeadline}
- onChange={(e) => setPreQuoteDeadline(e.target.value)}
- className="w-full"
- />
- </div>
-
- {/* 기존 계약 정보 알림 */}
- {existingContracts.length > 0 && (
- <Alert className="border-orange-500 bg-orange-50">
- <Info className="h-4 w-4 text-orange-600" />
- <AlertTitle className="text-orange-800">기존 계약 정보</AlertTitle>
- <AlertDescription className="text-orange-700">
- 이미 기본계약을 받은 업체가 있습니다.
- 해당 업체들은 초대 대상에서 제외되며, 계약서 재생성도 건너뜁니다.
- </AlertDescription>
- </Alert>
- )}
-
- {/* 업체 선택 섹션 */}
- <Card className="border-2 border-dashed">
- <CardHeader className="pb-3">
- <CardTitle className="flex items-center gap-2 text-base">
- <Building2 className="h-5 w-5 text-green-600" />
- 초대 대상 업체
- </CardTitle>
- </CardHeader>
- <CardContent className="space-y-4">
- {invitableCompanies.length === 0 ? (
- <div className="text-center py-8 text-muted-foreground">
- 초대 가능한 업체가 없습니다.
- </div>
- ) : (
- <>
- <div className="flex items-center justify-between p-3 bg-muted/50 rounded-lg">
- <div className="flex items-center gap-2">
- <Checkbox
- id="select-all-companies"
- checked={selectedAvailableCompanyCount === availableCompanies.length && availableCompanies.length > 0}
- onCheckedChange={handleSelectAll}
- />
- <Label htmlFor="select-all-companies" className="font-medium">
- 전체 선택 ({availableCompanies.length}개 업체)
- </Label>
- </div>
- <Badge variant="outline">
- {selectedCompanyCount}개 선택됨
- </Badge>
- </div>
-
- <div className="space-y-3 max-h-80 overflow-y-auto">
- {invitableCompanies.map((company) => {
- const hasExistingContract = existingContracts.some(ec => ec.vendorId === company.companyId);
- return (
- <div key={company.id} className={cn("flex items-center space-x-3 p-3 border rounded-lg transition-colors",
- selectedCompanyIds.includes(company.id) && !hasExistingContract && "border-green-500 bg-green-50",
- hasExistingContract && "border-orange-500 bg-orange-50 opacity-75"
- )}>
- <Checkbox
- id={`company-${company.id}`}
- checked={selectedCompanyIds.includes(company.id)}
- disabled={hasExistingContract}
- onCheckedChange={(checked) => handleSelectCompany(company.id, !!checked)}
- />
- <div className="flex-1">
- <div className="flex items-center gap-2">
- <span className={cn("font-medium", hasExistingContract && "text-muted-foreground")}>
- {company.companyName}
- </span>
- <Badge variant="outline" className="text-xs">
- {company.companyCode}
- </Badge>
- {hasExistingContract && (
- <Badge variant="secondary" className="text-xs">
- <CheckCircle className="h-3 w-3 mr-1" />
- 계약 체결됨
- </Badge>
- )}
- </div>
- {hasExistingContract && (
- <p className="text-xs text-orange-600 mt-1">
- 이미 기본계약서를 받은 업체입니다. 선택에서 제외됩니다.
- </p>
- )}
- </div>
- </div>
- )
- })}
- </div>
- </>
- )}
- </CardContent>
- </Card>
-
- {/* 선택된 업체 중 기존 계약이 있는 경우 경고 */}
- {selectedCompaniesWithExistingContracts.length > 0 && (
- <Alert className="border-red-500 bg-red-50">
- <Info className="h-4 w-4 text-red-600" />
- <AlertTitle className="text-red-800">선택한 업체 중 제외될 업체</AlertTitle>
- <AlertDescription className="text-red-700">
- 선택한 {selectedCompaniesWithExistingContracts.length}개 업체가 이미 기본계약서를 받았습니다.
- 이 업체들은 초대 발송 및 계약서 생성에서 제외됩니다.
- <br />
- <strong>실제 발송 대상: {selectedCompanyCount - selectedCompaniesWithExistingContracts.length}개 업체</strong>
- </AlertDescription>
- </Alert>
- )}
-
- {/* 기본계약서 선택 섹션 */}
- <Separator />
- <Card className="border-2 border-dashed">
- <CardHeader className="pb-3">
- <CardTitle className="flex items-center gap-2 text-base">
- <FileText className="h-5 w-5 text-blue-600" />
- 기본계약서 선택 (선택된 업체에만 발송)
- </CardTitle>
- </CardHeader>
- <CardContent className="space-y-4">
- {isLoadingTemplates ? (
- <div className="text-center py-6">
- <RefreshCw className="h-6 w-6 animate-spin mx-auto mb-2 text-blue-600" />
- <p className="text-sm text-muted-foreground">기본계약서 템플릿을 불러오는 중...</p>
- </div>
- ) : (
- <div className="space-y-4">
- {selectedCompanyCount === 0 && (
- <Alert className="border-red-500 bg-red-50">
- <Info className="h-4 w-4 text-red-600" />
- <AlertTitle className="text-red-800">알림</AlertTitle>
- <AlertDescription className="text-red-700">
- 기본계약서를 발송할 업체를 먼저 선택해주세요.
- </AlertDescription>
- </Alert>
- )}
- {availableTemplates.length === 0 ? (
- <div className="text-center py-8 text-muted-foreground">
- <FileText className="h-12 w-12 mx-auto mb-4 opacity-50" />
- <p>사용 가능한 기본계약서 템플릿이 없습니다.</p>
- </div>
- ) : (
- <>
- <div className="flex items-center justify-between p-3 bg-muted/50 rounded-lg">
- <div className="flex items-center gap-2">
- <Checkbox
- id="select-all-contracts"
- checked={selectedContracts.length > 0 && selectedContracts.every(c => c.checked)}
- onCheckedChange={toggleAllContractSelection}
- />
- <Label htmlFor="select-all-contracts" className="font-medium">
- 전체 선택 ({availableTemplates.length}개 템플릿)
- </Label>
- </div>
- <Badge variant="outline">
- {selectedContractCount}개 선택됨
- </Badge>
- </div>
- <div className="grid gap-3 max-h-60 overflow-y-auto">
- {selectedContracts.map((contract) => (
- <div
- key={contract.templateId}
- className={cn(
- "flex items-center justify-between p-3 border rounded-lg hover:bg-muted/50 transition-colors cursor-pointer",
- contract.checked && "border-blue-500 bg-blue-50"
- )}
- onClick={() => toggleContractSelection(contract.templateId)}
- >
- <div className="flex items-center gap-3">
- <Checkbox
- id={`contract-${contract.templateId}`}
- checked={contract.checked}
- onCheckedChange={() => toggleContractSelection(contract.templateId)}
- />
- <div className="flex-1">
- <Label
- htmlFor={`contract-${contract.templateId}`}
- className="font-medium cursor-pointer"
- >
- {contract.templateName}
- </Label>
- <p className="text-xs text-muted-foreground mt-1">
- {contract.contractType}
- </p>
- </div>
- </div>
- </div>
- ))}
- </div>
- </>
- )}
- {selectedContractCount > 0 && (
- <div className="mt-4 p-3 bg-green-50 border border-green-200 rounded-lg">
- <div className="flex items-center gap-2 mb-2">
- <CheckCircle className="h-4 w-4 text-green-600" />
- <span className="font-medium text-green-900 text-sm">
- 선택된 기본계약서 ({selectedContractCount}개)
- </span>
- </div>
- <ul className="space-y-1 text-xs text-green-800 list-disc list-inside">
- {selectedContracts.filter(c => c.checked).map((contract) => (
- <li key={contract.templateId}>
- {contract.templateName}
- </li>
- ))}
- </ul>
- </div>
- )}
- </div>
- )}
- </CardContent>
- </Card>
-
- {/* 추가 메시지 */}
- <div className="space-y-2">
- <Label htmlFor="contractMessage" className="text-sm font-medium">
- 계약서 추가 메시지 (선택사항)
- </Label>
- <textarea
- id="contractMessage"
- className="w-full min-h-[60px] p-3 text-sm border rounded-lg resize-none focus:outline-none focus:ring-2 focus:ring-primary"
- placeholder="기본계약서와 함께 보낼 추가 메시지를 입력하세요..."
- value={additionalMessage}
- onChange={(e) => setAdditionalMessage(e.target.value)}
- />
- </div>
-
- {/* PDF 생성 진행 상황 */}
- {isGeneratingPdfs && (
- <Alert className="border-blue-500 bg-blue-50">
- <div className="space-y-3">
- <div className="flex items-center gap-2">
- <RefreshCw className="h-4 w-4 animate-spin text-blue-600" />
- <AlertTitle className="text-blue-800">기본계약서 생성 중</AlertTitle>
- </div>
- <AlertDescription>
- <div className="space-y-2">
- <p className="text-sm text-blue-700">{currentGeneratingContract}</p>
- <Progress value={pdfGenerationProgress} className="h-2" />
- <p className="text-xs text-blue-600">
- {Math.round(pdfGenerationProgress)}% 완료
- </p>
- </div>
- </AlertDescription>
- </div>
- </Alert>
- )}
- </div>
- </div>
-
- <DialogFooter className="flex-col sm:flex-row-reverse sm:justify-between items-center px-4 pt-4">
- <div className="flex gap-2 w-full sm:w-auto">
- <Button variant="outline" onClick={() => handleOpenChange(false)} className="w-full sm:w-auto">
- 취소
- </Button>
- <Button
- onClick={handleSendInvitations}
- disabled={isPending || selectedCompanyCount === 0 || isGeneratingPdfs}
- className="w-full sm:w-auto"
- >
- {isPending ? (
- <>
- <RefreshCw className="w-4 h-4 mr-2 animate-spin" />
- 발송 중...
- </>
- ) : (
- <>
- <Mail className="w-4 h-4 mr-2" />
- 초대 발송 및 계약서 생성
- </>
- )}
- </Button>
- </div>
- {/* {(selectedCompanyCount > 0 || selectedContractCount > 0) && (
- <div className="mt-4 sm:mt-0 text-sm text-muted-foreground">
- {selectedCompanyCount > 0 && (
- <p>
- <strong>{selectedCompanyCount}개 업체</strong>에 초대를 발송합니다.
- </p>
- )}
- {selectedContractCount > 0 && selectedCompanyCount > 0 && (
- <p>
- 이 중 <strong>{companiesToReceiveContracts.length}개 업체</strong>에 <strong>{selectedContractCount}개</strong>의 기본계약서를 발송합니다.
- </p>
- )}
- </div>
- )} */}
- </DialogFooter>
- </DialogContent>
- </Dialog>
- )
-} \ No newline at end of file
diff --git a/lib/bidding/pre-quote/table/bidding-pre-quote-item-details-dialog.tsx b/lib/bidding/pre-quote/table/bidding-pre-quote-item-details-dialog.tsx
deleted file mode 100644
index f676709c..00000000
--- a/lib/bidding/pre-quote/table/bidding-pre-quote-item-details-dialog.tsx
+++ /dev/null
@@ -1,125 +0,0 @@
-'use client'
-
-import * as React from 'react'
-import {
- Dialog,
- DialogContent,
- DialogDescription,
- DialogHeader,
- DialogTitle,
-} from '@/components/ui/dialog'
-import { PrItemsPricingTable } from '../../vendor/components/pr-items-pricing-table'
-import { getSavedPrItemQuotations } from '../service'
-
-interface PrItem {
- id: number
- itemNumber: string | null
- prNumber: string | null
- itemInfo: string | null
- materialDescription: string | null
- quantity: string | null
- quantityUnit: string | null
- totalWeight: string | null
- weightUnit: string | null
- currency: string | null
- requestedDeliveryDate: string | null
- hasSpecDocument: boolean | null
-}
-
-interface PrItemQuotation {
- prItemId: number
- bidUnitPrice: number
- bidAmount: number
- proposedDeliveryDate?: string
- technicalSpecification?: string
-}
-
-interface BiddingPreQuoteItemDetailsDialogProps {
- open: boolean
- onOpenChange: (open: boolean) => void
- biddingId: number
- biddingCompanyId: number
- companyName: string
- prItems: PrItem[]
- currency?: string
-}
-
-export function BiddingPreQuoteItemDetailsDialog({
- open,
- onOpenChange,
- biddingId,
- biddingCompanyId,
- companyName,
- prItems,
- currency = 'KRW'
-}: BiddingPreQuoteItemDetailsDialogProps) {
- const [prItemQuotations, setPrItemQuotations] = React.useState<PrItemQuotation[]>([])
- const [isLoading, setIsLoading] = React.useState(false)
-
- // 다이얼로그가 열릴 때 저장된 품목별 견적 데이터 로드
- React.useEffect(() => {
- if (open && biddingCompanyId) {
- loadSavedQuotations()
- }
- }, [open, biddingCompanyId])
-
- const loadSavedQuotations = async () => {
- setIsLoading(true)
- try {
- console.log('Loading saved quotations for biddingCompanyId:', biddingCompanyId)
- const savedQuotations = await getSavedPrItemQuotations(biddingCompanyId)
- console.log('Loaded saved quotations:', savedQuotations)
- setPrItemQuotations(savedQuotations)
- } catch (error) {
- console.error('Failed to load saved quotations:', error)
- } finally {
- setIsLoading(false)
- }
- }
-
- const handleQuotationsChange = (quotations: PrItemQuotation[]) => {
- // ReadOnly 모드이므로 변경사항을 저장하지 않음
- console.log('Quotations changed (readonly):', quotations)
- }
-
- const handleTotalAmountChange = (total: number) => {
- // ReadOnly 모드이므로 총 금액 변경을 처리하지 않음
- console.log('Total amount changed (readonly):', total)
- }
-
- return (
- <Dialog open={open} onOpenChange={onOpenChange}>
- <DialogContent className="max-w-7xl max-h-[90vh] overflow-y-auto">
- <DialogHeader>
- <DialogTitle className="flex items-center gap-2">
- <span>품목별 견적 상세</span>
- <span className="text-sm font-normal text-muted-foreground">
- - {companyName}
- </span>
- </DialogTitle>
- <DialogDescription>
- 협력업체가 제출한 품목별 견적 상세 정보입니다.
- </DialogDescription>
- </DialogHeader>
-
- {isLoading ? (
- <div className="flex items-center justify-center py-12">
- <div className="text-center">
- <div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary mx-auto mb-4"></div>
- <p className="text-muted-foreground">견적 정보를 불러오는 중...</p>
- </div>
- </div>
- ) : (
- <PrItemsPricingTable
- prItems={prItems}
- initialQuotations={prItemQuotations}
- currency={currency}
- onQuotationsChange={handleQuotationsChange}
- onTotalAmountChange={handleTotalAmountChange}
- readOnly={true}
- />
- )}
- </DialogContent>
- </Dialog>
- )
-}
diff --git a/lib/bidding/pre-quote/table/bidding-pre-quote-selection-dialog.tsx b/lib/bidding/pre-quote/table/bidding-pre-quote-selection-dialog.tsx
deleted file mode 100644
index e0194f2a..00000000
--- a/lib/bidding/pre-quote/table/bidding-pre-quote-selection-dialog.tsx
+++ /dev/null
@@ -1,157 +0,0 @@
-'use client'
-
-import * as React from 'react'
-import { BiddingCompany } from './bidding-pre-quote-vendor-columns'
-import { updatePreQuoteSelection } from '../service'
-import {
- Dialog,
- DialogContent,
- DialogDescription,
- DialogFooter,
- DialogHeader,
- DialogTitle,
-} from '@/components/ui/dialog'
-import { Button } from '@/components/ui/button'
-import { Badge } from '@/components/ui/badge'
-import { CheckCircle, XCircle, AlertCircle } from 'lucide-react'
-import { useToast } from '@/hooks/use-toast'
-import { useTransition } from 'react'
-
-interface BiddingPreQuoteSelectionDialogProps {
- open: boolean
- onOpenChange: (open: boolean) => void
- selectedCompanies: BiddingCompany[]
- onSuccess: () => void
-}
-
-export function BiddingPreQuoteSelectionDialog({
- open,
- onOpenChange,
- selectedCompanies,
- onSuccess
-}: BiddingPreQuoteSelectionDialogProps) {
- const { toast } = useToast()
- const [isPending, startTransition] = useTransition()
- // 선택된 업체들의 현재 상태 분석 (선정만 가능)
- const unselectedCompanies = selectedCompanies.filter(c => !c.isPreQuoteSelected)
- const hasQuotationCompanies = selectedCompanies.filter(c => c.preQuoteAmount && Number(c.preQuoteAmount) > 0)
-
- const handleConfirm = () => {
- const companyIds = selectedCompanies.map(c => c.id)
- const isSelected = true // 항상 선정으로 고정
-
- startTransition(async () => {
- const result = await updatePreQuoteSelection(
- companyIds,
- isSelected
- )
-
- if (result.success) {
- toast({
- title: '성공',
- description: result.message,
- })
- onSuccess()
- onOpenChange(false)
- } else {
- toast({
- title: '오류',
- description: result.error,
- variant: 'destructive',
- })
- }
- })
- }
-
- const getActionIcon = (isSelected: boolean) => {
- return isSelected ?
- <CheckCircle className="h-4 w-4 text-muted-foreground" /> :
- <CheckCircle className="h-4 w-4 text-green-600" />
- }
-
- return (
- <Dialog open={open} onOpenChange={onOpenChange}>
- <DialogContent className="sm:max-w-[600px]">
- <DialogHeader>
- <DialogTitle className="flex items-center gap-2">
- <AlertCircle className="h-5 w-5 text-amber-500" />
- 본입찰 선정 상태 변경
- </DialogTitle>
- <DialogDescription>
- 선택된 {selectedCompanies.length}개 업체의 본입찰 선정 상태를 변경합니다.
- </DialogDescription>
- </DialogHeader>
-
- <div className="space-y-4">
- {/* 견적 제출 여부 안내 */}
- {hasQuotationCompanies.length !== selectedCompanies.length && (
- <div className="bg-amber-50 border border-amber-200 rounded-lg p-3">
- <div className="flex items-center gap-2 text-amber-800">
- <AlertCircle className="h-4 w-4" />
- <span className="text-sm font-medium">알림</span>
- </div>
- <p className="text-sm text-amber-700 mt-1">
- 사전견적을 제출하지 않은 업체도 포함되어 있습니다.
- 견적 미제출 업체도 본입찰에 참여시키시겠습니까?
- </p>
- </div>
- )}
-
- {/* 업체 목록 */}
- <div className="border rounded-lg">
- <div className="p-3 bg-muted/50 border-b">
- <h4 className="font-medium">대상 업체 목록</h4>
- </div>
- <div className="max-h-64 overflow-y-auto">
- {selectedCompanies.map((company) => (
- <div key={company.id} className="flex items-center justify-between p-3 border-b last:border-b-0">
- <div className="flex items-center gap-3">
- {getActionIcon(company.isPreQuoteSelected)}
- <div>
- <div className="font-medium">{company.companyName}</div>
- <div className="text-sm text-muted-foreground">{company.companyCode}</div>
- </div>
- </div>
- <div className="flex items-center gap-2">
- <Badge variant={company.isPreQuoteSelected ? 'default' : 'secondary'}>
- {company.isPreQuoteSelected ? '현재 선정' : '현재 미선정'}
- </Badge>
- {company.preQuoteAmount && Number(company.preQuoteAmount) > 0 ? (
- <Badge variant="outline" className="text-green-600">
- 견적 제출
- </Badge>
- ) : (
- <Badge variant="outline" className="text-muted-foreground">
- 견적 미제출
- </Badge>
- )}
- </div>
- </div>
- ))}
- </div>
- </div>
-
- {/* 결과 요약 */}
- <div className="bg-blue-50 border border-blue-200 rounded-lg p-3">
- <h5 className="font-medium text-blue-900 mb-2">변경 결과</h5>
- <div className="text-sm text-blue-800">
- <p>• {unselectedCompanies.length}개 업체가 본입찰 대상으로 <span className="font-medium text-green-600">선정</span>됩니다.</p>
- {selectedCompanies.length > unselectedCompanies.length && (
- <p>• {selectedCompanies.length - unselectedCompanies.length}개 업체는 이미 선정 상태이므로 변경되지 않습니다.</p>
- )}
- </div>
- </div>
- </div>
-
- <DialogFooter>
- <Button variant="outline" onClick={() => onOpenChange(false)}>
- 취소
- </Button>
- <Button onClick={handleConfirm} disabled={isPending}>
- {isPending ? '처리 중...' : '확인'}
- </Button>
- </DialogFooter>
- </DialogContent>
- </Dialog>
- )
-}
diff --git a/lib/bidding/pre-quote/table/bidding-pre-quote-vendor-columns.tsx b/lib/bidding/pre-quote/table/bidding-pre-quote-vendor-columns.tsx
deleted file mode 100644
index 3266a568..00000000
--- a/lib/bidding/pre-quote/table/bidding-pre-quote-vendor-columns.tsx
+++ /dev/null
@@ -1,398 +0,0 @@
-"use client"
-
-import * as React from "react"
-import { type ColumnDef } from "@tanstack/react-table"
-import { Checkbox } from "@/components/ui/checkbox"
-import { Badge } from "@/components/ui/badge"
-import { Button } from "@/components/ui/button"
-import {
- MoreHorizontal, Edit, Trash2, Paperclip
-} from "lucide-react"
-import {
- DropdownMenu,
- DropdownMenuContent,
- DropdownMenuItem,
- DropdownMenuLabel,
- DropdownMenuSeparator,
- DropdownMenuTrigger,
-} from "@/components/ui/dropdown-menu"
-
-// bidding_companies 테이블 타입 정의 (company_condition_responses와 join)
-export interface BiddingCompany {
- id: number
- biddingId: number
- companyId: number
- invitationStatus: 'pending' | 'sent' | 'accepted' | 'declined' | 'submitted'
- invitedAt: Date | null
- respondedAt: Date | null
- preQuoteAmount: string | null
- preQuoteSubmittedAt: Date | null
- preQuoteDeadline: Date | null
- isPreQuoteSelected: boolean
- isPreQuoteParticipated: boolean | null
- isAttendingMeeting: boolean | null
- notes: string | null
- contactPerson: string | null
- contactEmail: string | null
- contactPhone: string | null
- createdAt: Date
- updatedAt: Date
-
- // company_condition_responses 필드들
- paymentTermsResponse: string | null
- taxConditionsResponse: string | null
- proposedContractDeliveryDate: string | null
- priceAdjustmentResponse: boolean | null
- isInitialResponse: boolean | null
- incotermsResponse: string | null
- proposedShippingPort: string | null
- proposedDestinationPort: string | null
- sparePartResponse: string | null
- additionalProposals: string | null
-
- // 조인된 업체 정보
- companyName?: string
- companyCode?: string
-}
-
-interface GetBiddingCompanyColumnsProps {
- onEdit: (company: BiddingCompany) => void
- onDelete: (company: BiddingCompany) => void
- onViewPriceAdjustment?: (company: BiddingCompany) => void
- onViewItemDetails?: (company: BiddingCompany) => void
- onViewAttachments?: (company: BiddingCompany) => void
-}
-
-export function getBiddingPreQuoteVendorColumns({
- onEdit,
- onDelete,
- onViewPriceAdjustment,
- onViewItemDetails,
- onViewAttachments
-}: GetBiddingCompanyColumnsProps): ColumnDef<BiddingCompany>[] {
- return [
- {
- id: 'select',
- header: ({ table }) => (
- <Checkbox
- checked={table.getIsAllPageRowsSelected()}
- onCheckedChange={(value) => table.toggleAllPageRowsSelected(!!value)}
- aria-label="모두 선택"
- />
- ),
- cell: ({ row }) => (
- <Checkbox
- checked={row.getIsSelected()}
- onCheckedChange={(value) => row.toggleSelected(!!value)}
- aria-label="행 선택"
- />
- ),
- enableSorting: false,
- enableHiding: false,
- },
- {
- accessorKey: 'companyName',
- header: '업체명',
- cell: ({ row }) => (
- <div className="font-medium">{row.original.companyName || '-'}</div>
- ),
- },
- {
- accessorKey: 'companyCode',
- header: '업체코드',
- cell: ({ row }) => (
- <div className="font-mono text-sm">{row.original.companyCode || '-'}</div>
- ),
- },
- {
- accessorKey: 'invitationStatus',
- header: '초대 상태',
- cell: ({ row }) => {
- const status = row.original.invitationStatus
- let variant: any
- let label: string
-
- if (status === 'accepted') {
- variant = 'default'
- label = '수락'
- } else if (status === 'declined') {
- variant = 'destructive'
- label = '거절'
- } else if (status === 'pending') {
- variant = 'outline'
- label = '대기중'
- } else if (status === 'sent') {
- variant = 'outline'
- label = '요청됨'
- } else if (status === 'submitted') {
- variant = 'outline'
- label = '제출됨'
- } else {
- variant = 'outline'
- label = status || '-'
- }
-
- return <Badge variant={variant}>{label}</Badge>
- },
- },
- {
- accessorKey: 'preQuoteAmount',
- header: '사전견적금액',
- cell: ({ row }) => {
- const hasAmount = row.original.preQuoteAmount && Number(row.original.preQuoteAmount) > 0
- return (
- <div className="text-right font-mono">
- {hasAmount ? (
- <button
- onClick={() => onViewItemDetails?.(row.original)}
- className="text-primary hover:text-primary/80 hover:underline cursor-pointer"
- title="품목별 견적 상세 보기"
- >
- {Number(row.original.preQuoteAmount).toLocaleString()} KRW
- </button>
- ) : (
- <span className="text-muted-foreground">-</span>
- )}
- </div>
- )
- },
- },
- {
- accessorKey: 'preQuoteSubmittedAt',
- header: '사전견적 제출일',
- cell: ({ row }) => (
- <div className="text-sm">
- {row.original.preQuoteSubmittedAt ? new Date(row.original.preQuoteSubmittedAt).toLocaleDateString('ko-KR') : '-'}
- </div>
- ),
- },
- {
- accessorKey: 'preQuoteDeadline',
- header: '사전견적 마감일',
- cell: ({ row }) => {
- const deadline = row.original.preQuoteDeadline
- if (!deadline) {
- return <div className="text-muted-foreground text-sm">-</div>
- }
-
- const now = new Date()
- const deadlineDate = new Date(deadline)
- const isExpired = deadlineDate < now
-
- return (
- <div className={`text-sm ${isExpired ? 'text-red-600' : ''}`}>
- <div>{deadlineDate.toLocaleDateString('ko-KR')}</div>
- {isExpired && (
- <Badge variant="destructive" className="text-xs mt-1">
- 마감
- </Badge>
- )}
- </div>
- )
- },
- },
- {
- accessorKey: 'attachments',
- header: '첨부파일',
- cell: ({ row }) => {
- const hasAttachments = row.original.preQuoteSubmittedAt // 제출된 경우에만 첨부파일이 있을 수 있음
- return (
- <div className="text-center">
- {hasAttachments ? (
- <Button
- variant="ghost"
- size="sm"
- onClick={() => onViewAttachments?.(row.original)}
- className="h-8 w-8 p-0"
- title="첨부파일 보기"
- >
- <Paperclip className="h-4 w-4" />
- </Button>
- ) : (
- <span className="text-muted-foreground text-sm">-</span>
- )}
- </div>
- )
- },
- },
- {
- accessorKey: 'isPreQuoteParticipated',
- header: '사전견적 참여의사',
- cell: ({ row }) => {
- const participated = row.original.isPreQuoteParticipated
- if (participated === null) {
- return <Badge variant="outline">미결정</Badge>
- }
- return (
- <Badge variant={participated ? 'default' : 'destructive'}>
- {participated ? '참여' : '미참여'}
- </Badge>
- )
- },
- },
- {
- accessorKey: 'isPreQuoteSelected',
- header: '본입찰 선정',
- cell: ({ row }) => (
- <Badge variant={row.original.isPreQuoteSelected ? 'default' : 'secondary'}>
- {row.original.isPreQuoteSelected ? '선정' : '미선정'}
- </Badge>
- ),
- },
- {
- accessorKey: 'isAttendingMeeting',
- header: '사양설명회 참석',
- cell: ({ row }) => {
- const isAttending = row.original.isAttendingMeeting
- if (isAttending === null) return <div className="text-sm">-</div>
- return (
- <Badge variant={isAttending ? 'default' : 'secondary'}>
- {isAttending ? '참석' : '불참석'}
- </Badge>
- )
- },
- },
- {
- accessorKey: 'paymentTermsResponse',
- header: '지급조건',
- cell: ({ row }) => (
- <div className="text-sm max-w-32 truncate" title={row.original.paymentTermsResponse || ''}>
- {row.original.paymentTermsResponse || '-'}
- </div>
- ),
- },
- {
- accessorKey: 'taxConditionsResponse',
- header: '세금조건',
- cell: ({ row }) => (
- <div className="text-sm max-w-32 truncate" title={row.original.taxConditionsResponse || ''}>
- {row.original.taxConditionsResponse || '-'}
- </div>
- ),
- },
- {
- accessorKey: 'incotermsResponse',
- header: '운송조건',
- cell: ({ row }) => (
- <div className="text-sm max-w-24 truncate" title={row.original.incotermsResponse || ''}>
- {row.original.incotermsResponse || '-'}
- </div>
- ),
- },
- {
- accessorKey: 'isInitialResponse',
- header: '초도여부',
- cell: ({ row }) => {
- const isInitial = row.original.isInitialResponse
- if (isInitial === null) return <div className="text-sm">-</div>
- return (
- <Badge variant={isInitial ? 'default' : 'secondary'}>
- {isInitial ? 'Y' : 'N'}
- </Badge>
- )
- },
- },
- {
- accessorKey: 'priceAdjustmentResponse',
- header: '연동제',
- cell: ({ row }) => {
- const hasPriceAdjustment = row.original.priceAdjustmentResponse
- if (hasPriceAdjustment === null) return <div className="text-sm">-</div>
- return (
- <div className="flex items-center gap-2">
- <Badge variant={hasPriceAdjustment ? 'default' : 'secondary'}>
- {hasPriceAdjustment ? '적용' : '미적용'}
- </Badge>
- {hasPriceAdjustment && onViewPriceAdjustment && (
- <Button
- variant="ghost"
- size="sm"
- onClick={() => onViewPriceAdjustment(row.original)}
- className="h-6 px-2 text-xs"
- >
- 상세
- </Button>
- )}
- </div>
- )
- },
- },
- {
- accessorKey: 'proposedContractDeliveryDate',
- header: '제안납기일',
- cell: ({ row }) => (
- <div className="text-sm">
- {row.original.proposedContractDeliveryDate ?
- new Date(row.original.proposedContractDeliveryDate).toLocaleDateString('ko-KR') : '-'}
- </div>
- ),
- },
- {
- accessorKey: 'proposedShippingPort',
- header: '제안선적지',
- cell: ({ row }) => (
- <div className="text-sm max-w-24 truncate" title={row.original.proposedShippingPort || ''}>
- {row.original.proposedShippingPort || '-'}
- </div>
- ),
- },
- {
- accessorKey: 'proposedDestinationPort',
- header: '제안하역지',
- cell: ({ row }) => (
- <div className="text-sm max-w-24 truncate" title={row.original.proposedDestinationPort || ''}>
- {row.original.proposedDestinationPort || '-'}
- </div>
- ),
- },
- {
- accessorKey: 'sparePartResponse',
- header: '스페어파트',
- cell: ({ row }) => (
- <div className="text-sm max-w-24 truncate" title={row.original.sparePartResponse || ''}>
- {row.original.sparePartResponse || '-'}
- </div>
- ),
- },
- {
- accessorKey: 'additionalProposals',
- header: '추가제안',
- cell: ({ row }) => (
- <div className="text-sm max-w-32 truncate" title={row.original.additionalProposals || ''}>
- {row.original.additionalProposals || '-'}
- </div>
- ),
- },
- {
- id: 'actions',
- header: '액션',
- cell: ({ row }) => {
- const company = row.original
-
- return (
- <DropdownMenu>
- <DropdownMenuTrigger asChild>
- <Button variant="ghost" className="h-8 w-8 p-0">
- <span className="sr-only">메뉴 열기</span>
- <MoreHorizontal className="h-4 w-4" />
- </Button>
- </DropdownMenuTrigger>
- <DropdownMenuContent align="end">
- {/* <DropdownMenuItem onClick={() => onEdit(company)}>
- <Edit className="mr-2 h-4 w-4" />
- 수정
- </DropdownMenuItem> */}
- <DropdownMenuItem
- onClick={() => onDelete(company)}
- className="text-destructive"
- >
- <Trash2 className="mr-2 h-4 w-4" />
- 삭제
- </DropdownMenuItem>
- </DropdownMenuContent>
- </DropdownMenu>
- )
- },
- },
- ]
-}
diff --git a/lib/bidding/pre-quote/table/bidding-pre-quote-vendor-create-dialog.tsx b/lib/bidding/pre-quote/table/bidding-pre-quote-vendor-create-dialog.tsx
deleted file mode 100644
index bd078192..00000000
--- a/lib/bidding/pre-quote/table/bidding-pre-quote-vendor-create-dialog.tsx
+++ /dev/null
@@ -1,311 +0,0 @@
-'use client'
-
-import * as React from 'react'
-import { Button } from '@/components/ui/button'
-import { Label } from '@/components/ui/label'
-import {
- Dialog,
- DialogContent,
- DialogDescription,
- DialogFooter,
- DialogHeader,
- DialogTitle,
-} from '@/components/ui/dialog'
-import {
- Command,
- CommandEmpty,
- CommandGroup,
- CommandInput,
- CommandItem,
- CommandList,
-} from '@/components/ui/command'
-import {
- Popover,
- PopoverContent,
- PopoverTrigger
-} from '@/components/ui/popover'
-import { Check, ChevronsUpDown, Loader2, X, Plus, Search } from 'lucide-react'
-import { cn } from '@/lib/utils'
-import { createBiddingCompany } from '@/lib/bidding/pre-quote/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'
-import { ScrollArea } from '@/components/ui/scroll-area'
-import { Alert, AlertDescription } from '@/components/ui/alert'
-import { Info } from 'lucide-react'
-
-interface BiddingPreQuoteVendorCreateDialogProps {
- biddingId: number
- open: boolean
- onOpenChange: (open: boolean) => void
- onSuccess: () => void
-}
-
-interface Vendor {
- id: number
- vendorName: string
- vendorCode: string
- status: string
-}
-
-export function BiddingPreQuoteVendorCreateDialog({
- biddingId,
- open,
- onOpenChange,
- onSuccess
-}: BiddingPreQuoteVendorCreateDialogProps) {
- const { toast } = useToast()
- const [isPending, startTransition] = useTransition()
-
- // Vendor 검색 상태
- const [vendorList, setVendorList] = React.useState<Vendor[]>([])
- const [selectedVendors, setSelectedVendors] = React.useState<Vendor[]>([])
- const [vendorOpen, setVendorOpen] = React.useState(false)
-
-
- // 벤더 로드
- 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([])
- }
- }, [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 createBiddingCompany({
- biddingId,
- companyId: 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([])
- }
-
- return (
- <Dialog open={open} onOpenChange={onOpenChange}>
- <DialogContent className="max-w-4xl max-h-[90vh] p-0 flex flex-col">
- {/* 헤더 */}
- <DialogHeader className="p-6 pb-0">
- <DialogTitle>사전견적 업체 추가</DialogTitle>
- <DialogDescription>
- 견적 요청을 보낼 업체를 선택하세요. 여러 개 선택 가능합니다.
- </DialogDescription>
- </DialogHeader>
-
- {/* 메인 컨텐츠 */}
- <div className="flex-1 px-6 py-4 overflow-y-auto">
- <div className="space-y-6">
- {/* 업체 선택 카드 */}
- <Card>
- <CardHeader>
- <CardTitle className="text-lg">업체 선택</CardTitle>
- <CardDescription>
- 사전견적을 발송할 업체를 선택하세요.
- </CardDescription>
- </CardHeader>
- <CardContent>
- <div className="space-y-4">
- {/* 업체 추가 버튼 */}
- <Popover open={vendorOpen} onOpenChange={setVendorOpen}>
- <PopoverTrigger asChild>
- <Button
- variant="outline"
- role="combobox"
- aria-expanded={vendorOpen}
- className="w-full justify-between"
- disabled={vendorList.length === 0}
- >
- <span className="flex items-center gap-2">
- <Plus className="h-4 w-4" />
- 업체 선택하기
- </span>
- <ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
- </Button>
- </PopoverTrigger>
- <PopoverContent className="w-[500px] p-0" align="start">
- <Command>
- <CommandInput placeholder="업체명 또는 코드로 검색..." />
- <CommandList>
- <CommandEmpty>검색 결과가 없습니다.</CommandEmpty>
- <CommandGroup>
- {vendorList
- .filter(vendor => !isVendorSelected(vendor.id))
- .map((vendor) => (
- <CommandItem
- key={vendor.id}
- value={`${vendor.vendorCode} ${vendor.vendorName}`}
- onSelect={() => handleAddVendor(vendor)}
- >
- <div className="flex items-center gap-2 w-full">
- <Badge variant="outline" className="shrink-0">
- {vendor.vendorCode}
- </Badge>
- <span className="truncate">{vendor.vendorName}</span>
- </div>
- </CommandItem>
- ))}
- </CommandGroup>
- </CommandList>
- </Command>
- </PopoverContent>
- </Popover>
-
- {/* 선택된 업체 목록 */}
- {selectedVendors.length > 0 && (
- <div className="space-y-2">
- <div className="flex items-center justify-between">
- <h4 className="text-sm font-medium">선택된 업체 ({selectedVendors.length}개)</h4>
- </div>
- <div className="space-y-2">
- {selectedVendors.map((vendor, index) => (
- <div
- key={vendor.id}
- className="flex items-center justify-between p-3 rounded-lg bg-secondary/50"
- >
- <div className="flex items-center gap-3">
- <span className="text-sm text-muted-foreground">
- {index + 1}.
- </span>
- <Badge variant="outline">
- {vendor.vendorCode}
- </Badge>
- <span className="text-sm font-medium">
- {vendor.vendorName}
- </span>
- </div>
- <Button
- variant="ghost"
- size="sm"
- onClick={() => handleRemoveVendor(vendor.id)}
- className="h-8 w-8 p-0"
- >
- <X className="h-4 w-4" />
- </Button>
- </div>
- ))}
- </div>
- </div>
- )}
-
- {selectedVendors.length === 0 && (
- <div className="text-center py-8 text-muted-foreground">
- <p className="text-sm">아직 선택된 업체가 없습니다.</p>
- <p className="text-xs mt-1">위 버튼을 클릭하여 업체를 추가하세요.</p>
- </div>
- )}
- </div>
- </CardContent>
- </Card>
- </div>
- </div>
-
- {/* 푸터 */}
- <DialogFooter className="p-6 pt-0 border-t">
- <Button
- variant="outline"
- onClick={() => onOpenChange(false)}
- disabled={isPending}
- >
- 취소
- </Button>
- <Button
- onClick={handleCreate}
- disabled={isPending || selectedVendors.length === 0}
- >
- {isPending && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
- {selectedVendors.length > 0
- ? `${selectedVendors.length}개 업체 추가`
- : '업체 추가'
- }
- </Button>
- </DialogFooter>
- </DialogContent>
- </Dialog>
- )
-}
diff --git a/lib/bidding/pre-quote/table/bidding-pre-quote-vendor-edit-dialog.tsx b/lib/bidding/pre-quote/table/bidding-pre-quote-vendor-edit-dialog.tsx
deleted file mode 100644
index 03bf2ecb..00000000
--- a/lib/bidding/pre-quote/table/bidding-pre-quote-vendor-edit-dialog.tsx
+++ /dev/null
@@ -1,200 +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 { updateBiddingCompany } from '../service'
-import { BiddingCompany } from './bidding-pre-quote-vendor-columns'
-import { useToast } from '@/hooks/use-toast'
-import { useTransition } from 'react'
-
-interface BiddingPreQuoteVendorEditDialogProps {
- company: BiddingCompany | null
- open: boolean
- onOpenChange: (open: boolean) => void
- onSuccess: () => void
-}
-
-export function BiddingPreQuoteVendorEditDialog({
- company,
- open,
- onOpenChange,
- onSuccess
-}: BiddingPreQuoteVendorEditDialogProps) {
- const { toast } = useToast()
- const [isPending, startTransition] = useTransition()
-
- // 폼 상태
- const [formData, setFormData] = React.useState({
- contactPerson: '',
- contactEmail: '',
- contactPhone: '',
- preQuoteAmount: 0,
- notes: '',
- invitationStatus: 'pending' as 'pending' | 'accepted' | 'declined',
- isPreQuoteSelected: false,
- isAttendingMeeting: false,
- })
-
- // company가 변경되면 폼 데이터 업데이트
- React.useEffect(() => {
- if (company) {
- setFormData({
- contactPerson: company.contactPerson || '',
- contactEmail: company.contactEmail || '',
- contactPhone: company.contactPhone || '',
- preQuoteAmount: company.preQuoteAmount ? Number(company.preQuoteAmount) : 0,
- notes: company.notes || '',
- invitationStatus: company.invitationStatus,
- isPreQuoteSelected: company.isPreQuoteSelected,
- isAttendingMeeting: company.isAttendingMeeting || false,
- })
- }
- }, [company])
-
- const handleEdit = () => {
- if (!company) return
-
- startTransition(async () => {
- const response = await updateBiddingCompany(company.id, formData)
-
- if (response.success) {
- toast({
- title: '성공',
- description: response.message,
- })
- onOpenChange(false)
- onSuccess()
- } else {
- toast({
- title: '오류',
- description: response.error,
- variant: 'destructive',
- })
- }
- })
- }
-
- return (
- <Dialog open={open} onOpenChange={onOpenChange}>
- <DialogContent className="sm:max-w-[600px]">
- <DialogHeader>
- <DialogTitle>사전견적 업체 수정</DialogTitle>
- <DialogDescription>
- {company?.companyName} 업체의 사전견적 정보를 수정해주세요.
- </DialogDescription>
- </DialogHeader>
- <div className="grid gap-4 py-4">
- <div className="grid grid-cols-3 gap-4">
- <div className="space-y-2">
- <Label htmlFor="edit-contactPerson">담당자</Label>
- <Input
- id="edit-contactPerson"
- value={formData.contactPerson}
- onChange={(e) => setFormData({ ...formData, contactPerson: e.target.value })}
- />
- </div>
- <div className="space-y-2">
- <Label htmlFor="edit-contactEmail">이메일</Label>
- <Input
- id="edit-contactEmail"
- type="email"
- value={formData.contactEmail}
- onChange={(e) => setFormData({ ...formData, contactEmail: e.target.value })}
- />
- </div>
- <div className="space-y-2">
- <Label htmlFor="edit-contactPhone">연락처</Label>
- <Input
- id="edit-contactPhone"
- value={formData.contactPhone}
- onChange={(e) => setFormData({ ...formData, contactPhone: e.target.value })}
- />
- </div>
- </div>
- <div className="grid grid-cols-2 gap-4">
- <div className="space-y-2">
- <Label htmlFor="edit-preQuoteAmount">사전견적금액</Label>
- <Input
- id="edit-preQuoteAmount"
- type="number"
- value={formData.preQuoteAmount}
- onChange={(e) => setFormData({ ...formData, preQuoteAmount: Number(e.target.value) })}
- />
- </div>
- <div className="space-y-2">
- <Label htmlFor="edit-invitationStatus">초대 상태</Label>
- <Select value={formData.invitationStatus} onValueChange={(value: any) => setFormData({ ...formData, invitationStatus: value })}>
- <SelectTrigger>
- <SelectValue />
- </SelectTrigger>
- <SelectContent>
- <SelectItem value="pending">대기중</SelectItem>
- <SelectItem value="accepted">수락</SelectItem>
- <SelectItem value="declined">거절</SelectItem>
- </SelectContent>
- </Select>
- </div>
- </div>
- <div className="grid grid-cols-2 gap-4">
- <div className="flex items-center space-x-2">
- <Checkbox
- id="edit-isPreQuoteSelected"
- checked={formData.isPreQuoteSelected}
- onCheckedChange={(checked) =>
- setFormData({ ...formData, isPreQuoteSelected: !!checked })
- }
- />
- <Label htmlFor="edit-isPreQuoteSelected">본입찰 선정</Label>
- </div>
- <div className="flex items-center space-x-2">
- <Checkbox
- id="edit-isAttendingMeeting"
- checked={formData.isAttendingMeeting}
- onCheckedChange={(checked) =>
- setFormData({ ...formData, isAttendingMeeting: !!checked })
- }
- />
- <Label htmlFor="edit-isAttendingMeeting">사양설명회 참석</Label>
- </div>
- </div>
- <div className="space-y-2">
- <Label htmlFor="edit-notes">특이사항</Label>
- <Textarea
- id="edit-notes"
- value={formData.notes}
- onChange={(e) => setFormData({ ...formData, notes: e.target.value })}
- placeholder="특이사항을 입력해주세요..."
- />
- </div>
- </div>
- <DialogFooter>
- <Button variant="outline" onClick={() => onOpenChange(false)}>
- 취소
- </Button>
- <Button onClick={handleEdit} disabled={isPending}>
- 수정
- </Button>
- </DialogFooter>
- </DialogContent>
- </Dialog>
- )
-}
diff --git a/lib/bidding/pre-quote/table/bidding-pre-quote-vendor-table.tsx b/lib/bidding/pre-quote/table/bidding-pre-quote-vendor-table.tsx
deleted file mode 100644
index 5f600882..00000000
--- a/lib/bidding/pre-quote/table/bidding-pre-quote-vendor-table.tsx
+++ /dev/null
@@ -1,257 +0,0 @@
-'use client'
-
-import * as React from 'react'
-import { type DataTableAdvancedFilterField, type DataTableFilterField } 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 { BiddingPreQuoteVendorToolbarActions } from './bidding-pre-quote-vendor-toolbar-actions'
-import { BiddingPreQuoteVendorEditDialog } from './bidding-pre-quote-vendor-edit-dialog'
-import { getBiddingPreQuoteVendorColumns, BiddingCompany } from './bidding-pre-quote-vendor-columns'
-import { Bidding } from '@/db/schema'
-import {
- deleteBiddingCompany
-} from '../service'
-import { getPriceAdjustmentFormByBiddingCompanyId } from '@/lib/bidding/detail/service'
-import { useToast } from '@/hooks/use-toast'
-import { useTransition } from 'react'
-import { PriceAdjustmentDialog } from '@/components/bidding/price-adjustment-dialog'
-import { BiddingPreQuoteItemDetailsDialog } from './bidding-pre-quote-item-details-dialog'
-import { BiddingPreQuoteAttachmentsDialog } from './bidding-pre-quote-attachments-dialog'
-import { getPrItemsForBidding } from '../service'
-
-interface BiddingPreQuoteVendorTableContentProps {
- biddingId: number
- bidding: Bidding
- biddingCompanies: BiddingCompany[]
- onRefresh: () => void
- onOpenItemsDialog: () => void
- onOpenTargetPriceDialog: () => void
- onOpenSelectionReasonDialog: () => void
- onEdit?: (company: BiddingCompany) => void
- onDelete?: (company: BiddingCompany) => void
-}
-
-const filterFields: DataTableFilterField<BiddingCompany>[] = [
- {
- id: 'companyName',
- label: '업체명',
- placeholder: '업체명으로 검색...',
- },
- {
- id: 'companyCode',
- label: '업체코드',
- placeholder: '업체코드로 검색...',
- },
- {
- id: 'contactPerson',
- label: '담당자',
- placeholder: '담당자로 검색...',
- },
-]
-
-const advancedFilterFields: DataTableAdvancedFilterField<BiddingCompany>[] = [
- {
- id: 'companyName',
- label: '업체명',
- type: 'text',
- },
- {
- id: 'companyCode',
- label: '업체코드',
- type: 'text',
- },
- {
- id: 'contactPerson',
- label: '담당자',
- type: 'text',
- },
- {
- id: 'preQuoteAmount',
- label: '사전견적금액',
- type: 'number',
- },
- {
- id: 'invitationStatus',
- label: '초대 상태',
- type: 'multi-select',
- options: [
- { label: '수락', value: 'accepted' },
- { label: '거절', value: 'declined' },
- { label: '요청됨', value: 'sent' },
- { label: '대기중', value: 'pending' },
- ],
- },
-]
-
-export function BiddingPreQuoteVendorTableContent({
- biddingId,
- bidding,
- biddingCompanies,
- onRefresh,
- onOpenItemsDialog,
- onOpenTargetPriceDialog,
- onOpenSelectionReasonDialog,
- onEdit,
- onDelete
-}: BiddingPreQuoteVendorTableContentProps) {
- const { toast } = useToast()
- const [isPending, startTransition] = useTransition()
- const [selectedCompany, setSelectedCompany] = React.useState<BiddingCompany | null>(null)
- const [isEditDialogOpen, setIsEditDialogOpen] = React.useState(false)
- const [isPriceAdjustmentDialogOpen, setIsPriceAdjustmentDialogOpen] = React.useState(false)
- const [priceAdjustmentData, setPriceAdjustmentData] = React.useState<any>(null)
- const [isItemDetailsDialogOpen, setIsItemDetailsDialogOpen] = React.useState(false)
- const [selectedCompanyForDetails, setSelectedCompanyForDetails] = React.useState<BiddingCompany | null>(null)
- const [prItems, setPrItems] = React.useState<any[]>([])
- const [isAttachmentsDialogOpen, setIsAttachmentsDialogOpen] = React.useState(false)
- const [selectedCompanyForAttachments, setSelectedCompanyForAttachments] = React.useState<BiddingCompany | null>(null)
-
- const handleDelete = (company: BiddingCompany) => {
- startTransition(async () => {
- const response = await deleteBiddingCompany(company.id)
-
- if (response.success) {
- toast({
- title: '성공',
- description: response.message,
- })
- onRefresh()
- } else {
- toast({
- title: '오류',
- description: response.error,
- variant: 'destructive',
- })
- }
- })
- }
-
- const handleEdit = (company: BiddingCompany) => {
- setSelectedCompany(company)
- setIsEditDialogOpen(true)
- }
-
-
- const handleViewPriceAdjustment = async (company: BiddingCompany) => {
- startTransition(async () => {
- const priceAdjustmentForm = await getPriceAdjustmentFormByBiddingCompanyId(company.id)
- if (priceAdjustmentForm) {
- setPriceAdjustmentData(priceAdjustmentForm)
- setSelectedCompany(company)
- setIsPriceAdjustmentDialogOpen(true)
- } else {
- toast({
- title: '정보 없음',
- description: '연동제 정보가 없습니다.',
- variant: 'destructive',
- })
- }
- })
- }
-
- const handleViewItemDetails = async (company: BiddingCompany) => {
- startTransition(async () => {
- try {
- // PR 아이템 정보 로드
- const prItemsData = await getPrItemsForBidding(biddingId)
- setPrItems(prItemsData)
- setSelectedCompanyForDetails(company)
- setIsItemDetailsDialogOpen(true)
- } catch (error) {
- console.error('Failed to load PR items:', error)
- toast({
- title: '오류',
- description: '품목 정보를 불러오는데 실패했습니다.',
- variant: 'destructive',
- })
- }
- })
- }
-
- const handleViewAttachments = (company: BiddingCompany) => {
- setSelectedCompanyForAttachments(company)
- setIsAttachmentsDialogOpen(true)
- }
-
- const columns = React.useMemo(
- () => getBiddingPreQuoteVendorColumns({
- onEdit: onEdit || handleEdit,
- onDelete: onDelete || handleDelete,
- onViewPriceAdjustment: handleViewPriceAdjustment,
- onViewItemDetails: handleViewItemDetails,
- onViewAttachments: handleViewAttachments
- }),
- [onEdit, onDelete, handleEdit, handleDelete, handleViewPriceAdjustment, handleViewItemDetails, handleViewAttachments]
- )
-
- const { table } = useDataTable({
- data: biddingCompanies,
- columns,
- pageCount: 1,
- filterFields,
- enableAdvancedFilter: true,
- initialState: {
- sorting: [{ id: 'companyName', desc: false }],
- columnPinning: { right: ['actions'] },
- },
- getRowId: (originalRow) => originalRow.id.toString(),
- shallow: false,
- clearOnDefault: true,
- })
-
- return (
- <>
- <DataTable table={table}>
- <DataTableAdvancedToolbar
- table={table}
- filterFields={advancedFilterFields}
- shallow={false}
- >
- <BiddingPreQuoteVendorToolbarActions
- table={table}
- biddingId={biddingId}
- bidding={bidding}
- biddingCompanies={biddingCompanies}
- onOpenItemsDialog={onOpenItemsDialog}
- onOpenTargetPriceDialog={onOpenTargetPriceDialog}
- onOpenSelectionReasonDialog={onOpenSelectionReasonDialog}
- onSuccess={onRefresh}
- />
- </DataTableAdvancedToolbar>
- </DataTable>
-
- <BiddingPreQuoteVendorEditDialog
- company={selectedCompany}
- open={isEditDialogOpen}
- onOpenChange={setIsEditDialogOpen}
- onSuccess={onRefresh}
- />
-
- <PriceAdjustmentDialog
- open={isPriceAdjustmentDialogOpen}
- onOpenChange={setIsPriceAdjustmentDialogOpen}
- data={priceAdjustmentData}
- vendorName={selectedCompany?.companyName || ''}
- />
-
- <BiddingPreQuoteItemDetailsDialog
- open={isItemDetailsDialogOpen}
- onOpenChange={setIsItemDetailsDialogOpen}
- biddingId={biddingId}
- biddingCompanyId={selectedCompanyForDetails?.id || 0}
- companyName={selectedCompanyForDetails?.companyName || ''}
- prItems={prItems}
- currency={bidding.currency || 'KRW'}
- />
-
- <BiddingPreQuoteAttachmentsDialog
- open={isAttachmentsDialogOpen}
- onOpenChange={setIsAttachmentsDialogOpen}
- biddingId={biddingId}
- companyId={selectedCompanyForAttachments?.companyId || 0}
- companyName={selectedCompanyForAttachments?.companyName || ''}
- />
- </>
- )
-}
diff --git a/lib/bidding/pre-quote/table/bidding-pre-quote-vendor-toolbar-actions.tsx b/lib/bidding/pre-quote/table/bidding-pre-quote-vendor-toolbar-actions.tsx
deleted file mode 100644
index 34e53fb2..00000000
--- a/lib/bidding/pre-quote/table/bidding-pre-quote-vendor-toolbar-actions.tsx
+++ /dev/null
@@ -1,130 +0,0 @@
-"use client"
-
-import * as React from "react"
-import { type Table } from "@tanstack/react-table"
-import { useTransition } from "react"
-import { Button } from "@/components/ui/button"
-import { Plus, Send, Mail, CheckSquare } from "lucide-react"
-import { BiddingCompany } from "./bidding-pre-quote-vendor-columns"
-import { BiddingPreQuoteVendorCreateDialog } from "./bidding-pre-quote-vendor-create-dialog"
-import { BiddingPreQuoteInvitationDialog } from "./bidding-pre-quote-invitation-dialog"
-import { BiddingPreQuoteSelectionDialog } from "./bidding-pre-quote-selection-dialog"
-import { Bidding } from "@/db/schema"
-import { useToast } from "@/hooks/use-toast"
-
-interface BiddingPreQuoteVendorToolbarActionsProps {
- table: Table<BiddingCompany>
- biddingId: number
- bidding: Bidding
- biddingCompanies: BiddingCompany[]
- onOpenItemsDialog: () => void
- onOpenTargetPriceDialog: () => void
- onOpenSelectionReasonDialog: () => void
- onSuccess: () => void
-}
-
-export function BiddingPreQuoteVendorToolbarActions({
- table,
- biddingId,
- bidding,
- biddingCompanies,
- onOpenItemsDialog,
- onOpenTargetPriceDialog,
- onOpenSelectionReasonDialog,
- onSuccess
-}: BiddingPreQuoteVendorToolbarActionsProps) {
- const { toast } = useToast()
- const [isPending, startTransition] = useTransition()
- const [isCreateDialogOpen, setIsCreateDialogOpen] = React.useState(false)
- const [isInvitationDialogOpen, setIsInvitationDialogOpen] = React.useState(false)
- const [isSelectionDialogOpen, setIsSelectionDialogOpen] = React.useState(false)
-
- const handleCreateCompany = () => {
- setIsCreateDialogOpen(true)
- }
-
- const handleSendInvitations = () => {
- setIsInvitationDialogOpen(true)
- }
-
- const handleManageSelection = () => {
- const selectedRows = table.getFilteredSelectedRowModel().rows
- if (selectedRows.length === 0) {
- toast({
- title: '선택 필요',
- description: '본입찰 선정 상태를 변경할 업체를 선택해주세요.',
- variant: 'destructive',
- })
- return
- }
- setIsSelectionDialogOpen(true)
- }
-
-
-
- return (
- <>
- <div className="flex items-center gap-2">
- <Button
- variant="outline"
- size="sm"
- onClick={handleCreateCompany}
- disabled={isPending}
- >
- <Plus className="mr-2 h-4 w-4" />
- 업체 추가
- </Button>
-
- <Button
- variant="default"
- size="sm"
- onClick={handleSendInvitations}
- disabled={isPending}
- >
- <Mail className="mr-2 h-4 w-4" />
- 초대 발송
- </Button>
-
- <Button
- variant="secondary"
- size="sm"
- onClick={handleManageSelection}
- disabled={isPending}
- >
- <CheckSquare className="mr-2 h-4 w-4" />
- 본입찰 선정
- </Button>
- </div>
-
- <BiddingPreQuoteVendorCreateDialog
- biddingId={biddingId}
- open={isCreateDialogOpen}
- onOpenChange={setIsCreateDialogOpen}
- onSuccess={() => {
- onSuccess()
- setIsCreateDialogOpen(false)
- }}
- />
-
- <BiddingPreQuoteInvitationDialog
- open={isInvitationDialogOpen}
- onOpenChange={setIsInvitationDialogOpen}
- companies={biddingCompanies}
- biddingId={biddingId}
- biddingTitle={bidding.title}
- projectName={bidding.projectName}
- onSuccess={onSuccess}
- />
-
- <BiddingPreQuoteSelectionDialog
- open={isSelectionDialogOpen}
- onOpenChange={setIsSelectionDialogOpen}
- selectedCompanies={table.getFilteredSelectedRowModel().rows.map(row => row.original)}
- onSuccess={() => {
- onSuccess()
- table.resetRowSelection()
- }}
- />
- </>
- )
-}
diff --git a/lib/bidding/receive/biddings-receive-columns.tsx b/lib/bidding/receive/biddings-receive-columns.tsx
new file mode 100644
index 00000000..724a7396
--- /dev/null
+++ b/lib/bidding/receive/biddings-receive-columns.tsx
@@ -0,0 +1,360 @@
+"use client"
+
+import * as React from "react"
+import { type ColumnDef } from "@tanstack/react-table"
+import { Badge } from "@/components/ui/badge"
+import { Button } from "@/components/ui/button"
+import {
+ Eye, Calendar, Users, CheckCircle, XCircle, Clock, AlertTriangle
+} from "lucide-react"
+import {
+ Tooltip,
+ TooltipContent,
+ TooltipProvider,
+ TooltipTrigger,
+} from "@/components/ui/tooltip"
+import {
+ DropdownMenu,
+ DropdownMenuContent,
+ DropdownMenuItem,
+ DropdownMenuSeparator,
+ DropdownMenuTrigger,
+} from "@/components/ui/dropdown-menu"
+import { DataTableColumnHeaderSimple } from "@/components/data-table/data-table-column-simple-header"
+import {
+ biddingStatusLabels,
+ contractTypeLabels,
+} from "@/db/schema"
+import { formatDate } from "@/lib/utils"
+import { DataTableRowAction } from "@/types/table"
+
+type BiddingReceiveItem = {
+ id: number
+ biddingNumber: string
+ originalBiddingNumber: string | null
+ title: string
+ status: string
+ contractType: string
+ prNumber: string | null
+ submissionStartDate: Date | null
+ submissionEndDate: Date | null
+ bidPicName: string | null
+ supplyPicName: string | null
+ createdBy: string | null
+ createdAt: Date | null
+ updatedAt: Date | null
+
+ // 참여 현황
+ participantExpected: number
+ participantParticipated: number
+ participantDeclined: number
+ participantPending: number
+
+ // 개찰 정보
+ openedAt: Date | null
+ openedBy: string | null
+}
+
+interface GetColumnsProps {
+ setRowAction: React.Dispatch<React.SetStateAction<DataTableRowAction<BiddingReceiveItem> | null>>
+}
+
+// 상태별 배지 색상
+const getStatusBadgeVariant = (status: string) => {
+ switch (status) {
+ case 'received_quotation':
+ return 'secondary'
+ case 'bidding_opened':
+ return 'default'
+ case 'bidding_closed':
+ return 'outline'
+ default:
+ return 'outline'
+ }
+}
+
+// 금액 포맷팅
+const formatCurrency = (amount: string | number | null, currency = 'KRW') => {
+ if (!amount) return '-'
+
+ const numAmount = typeof amount === 'string' ? parseFloat(amount) : amount
+ if (isNaN(numAmount)) return '-'
+
+ return new Intl.NumberFormat('ko-KR', {
+ style: 'currency',
+ currency: currency,
+ minimumFractionDigits: 0,
+ maximumFractionDigits: 0,
+ }).format(numAmount)
+}
+
+export function getBiddingsReceiveColumns({ setRowAction }: GetColumnsProps): ColumnDef<BiddingReceiveItem>[] {
+
+ return [
+ // ░░░ 입찰번호 ░░░
+ {
+ accessorKey: "biddingNumber",
+ header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="입찰번호" />,
+ cell: ({ row }) => (
+ <div className="font-mono text-sm">
+ {row.original.biddingNumber}
+ </div>
+ ),
+ size: 120,
+ meta: { excelHeader: "입찰번호" },
+ },
+
+ // ░░░ 입찰명 ░░░
+ {
+ accessorKey: "title",
+ header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="입찰명" />,
+ cell: ({ row }) => (
+ <div className="truncate max-w-[200px]" title={row.original.title}>
+ <Button
+ variant="link"
+ className="p-0 h-auto text-left justify-start font-bold underline"
+ onClick={() => setRowAction({ row, type: "view" })}
+ >
+ <div className="whitespace-pre-line">
+ {row.original.title}
+ </div>
+ </Button>
+ </div>
+ ),
+ size: 200,
+ meta: { excelHeader: "입찰명" },
+ },
+
+ // ░░░ 원입찰번호 ░░░
+ {
+ accessorKey: "originalBiddingNumber",
+ header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="원입찰번호" />,
+ cell: ({ row }) => (
+ <div className="font-mono text-sm">
+ {row.original.originalBiddingNumber || '-'}
+ </div>
+ ),
+ size: 120,
+ meta: { excelHeader: "원입찰번호" },
+ },
+
+ // ░░░ 진행상태 ░░░
+ {
+ accessorKey: "status",
+ header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="진행상태" />,
+ cell: ({ row }) => (
+ <Badge variant={getStatusBadgeVariant(row.original.status)}>
+ {biddingStatusLabels[row.original.status]}
+ </Badge>
+ ),
+ size: 120,
+ meta: { excelHeader: "진행상태" },
+ },
+
+ // ░░░ 계약구분 ░░░
+ {
+ accessorKey: "contractType",
+ header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="계약구분" />,
+ cell: ({ row }) => (
+ <Badge variant="outline">
+ {contractTypeLabels[row.original.contractType]}
+ </Badge>
+ ),
+ size: 100,
+ meta: { excelHeader: "계약구분" },
+ },
+
+ // ░░░ 입찰서제출기간 ░░░
+ {
+ id: "submissionPeriod",
+ header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="입찰서제출기간" />,
+ cell: ({ row }) => {
+ const startDate = row.original.submissionStartDate
+ const endDate = row.original.submissionEndDate
+
+ if (!startDate || !endDate) return <span className="text-muted-foreground">-</span>
+
+ const now = new Date()
+ const isActive = now >= new Date(startDate) && now <= new Date(endDate)
+ const isPast = now > new Date(endDate)
+
+ return (
+ <div className="text-xs">
+ <div className={`${isActive ? 'text-green-600 font-medium' : isPast ? 'text-red-600' : 'text-gray-600'}`}>
+ {formatDate(startDate, "KR")} ~ {formatDate(endDate, "KR")}
+ </div>
+ {isActive && (
+ <Badge variant="default" className="text-xs mt-1">진행중</Badge>
+ )}
+ </div>
+ )
+ },
+ size: 140,
+ meta: { excelHeader: "입찰서제출기간" },
+ },
+
+ // ░░░ P/R번호 ░░░
+ {
+ accessorKey: "prNumber",
+ header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="P/R번호" />,
+ cell: ({ row }) => (
+ <span className="font-mono text-sm">{row.original.prNumber || '-'}</span>
+ ),
+ size: 100,
+ meta: { excelHeader: "P/R번호" },
+ },
+
+ // ░░░ 입찰담당자 ░░░
+ {
+ accessorKey: "bidPicName",
+ header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="입찰담당자" />,
+ cell: ({ row }) => {
+ const bidPic = row.original.bidPicName
+ const supplyPic = row.original.supplyPicName
+
+ const displayName = bidPic || supplyPic || "-"
+ return <span className="text-sm">{displayName}</span>
+ },
+ size: 100,
+ meta: { excelHeader: "입찰담당자" },
+ },
+
+ // ░░░ 참여예정협력사 ░░░
+ {
+ id: "participantExpected",
+ header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="참여예정협력사" />,
+ cell: ({ row }) => (
+ <div className="flex items-center gap-1">
+ <Users className="h-4 w-4 text-blue-500" />
+ <span className="text-sm font-medium">{row.original.participantExpected}</span>
+ </div>
+ ),
+ size: 100,
+ meta: { excelHeader: "참여예정협력사" },
+ },
+
+ // ░░░ 참여협력사 ░░░
+ {
+ id: "participantParticipated",
+ header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="참여협력사" />,
+ cell: ({ row }) => (
+ <div className="flex items-center gap-1">
+ <CheckCircle className="h-4 w-4 text-green-500" />
+ <span className="text-sm font-medium">{row.original.participantParticipated}</span>
+ </div>
+ ),
+ size: 100,
+ meta: { excelHeader: "참여협력사" },
+ },
+
+ // ░░░ 포기협력사 ░░░
+ {
+ id: "participantDeclined",
+ header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="포기협력사" />,
+ cell: ({ row }) => (
+ <div className="flex items-center gap-1">
+ <XCircle className="h-4 w-4 text-red-500" />
+ <span className="text-sm font-medium">{row.original.participantDeclined}</span>
+ </div>
+ ),
+ size: 100,
+ meta: { excelHeader: "포기협력사" },
+ },
+
+ // ░░░ 미제출협력사 ░░░
+ {
+ id: "participantPending",
+ header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="미제출협력사" />,
+ cell: ({ row }) => (
+ <div className="flex items-center gap-1">
+ <Clock className="h-4 w-4 text-yellow-500" />
+ <span className="text-sm font-medium">{row.original.participantPending}</span>
+ </div>
+ ),
+ size: 100,
+ meta: { excelHeader: "미제출협력사" },
+ },
+
+ // ░░░ 개찰자명 ░░░
+ {
+ id: "openedBy",
+ header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="개찰자명" />,
+ cell: ({ row }) => {
+ const openedBy = row.original.openedBy
+ return <span className="text-sm">{openedBy || '-'}</span>
+ },
+ size: 100,
+ meta: { excelHeader: "개찰자명" },
+ },
+
+ // ░░░ 개찰일 ░░░
+ {
+ id: "openedAt",
+ header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="개찰일" />,
+ cell: ({ row }) => {
+ const openedAt = row.original.openedAt
+ return <span className="text-sm">{openedAt ? formatDate(openedAt, "KR") : '-'}</span>
+ },
+ size: 100,
+ meta: { excelHeader: "개찰일" },
+ },
+
+ // ░░░ 등록자 ░░░
+ {
+ accessorKey: "createdBy",
+ header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="등록자" />,
+ cell: ({ row }) => (
+ <span className="text-sm">{row.original.createdBy || '-'}</span>
+ ),
+ size: 100,
+ meta: { excelHeader: "등록자" },
+ },
+
+ // ░░░ 등록일시 ░░░
+ {
+ accessorKey: "createdAt",
+ header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="등록일시" />,
+ cell: ({ row }) => (
+ <span className="text-sm">{formatDate(row.original.createdAt, "KR")}</span>
+ ),
+ size: 100,
+ meta: { excelHeader: "등록일시" },
+ },
+
+ // ═══════════════════════════════════════════════════════════════
+ // 액션
+ // ═══════════════════════════════════════════════════════════════
+ {
+ id: "actions",
+ header: "액션",
+ cell: ({ row }) => (
+ <DropdownMenu>
+ <DropdownMenuTrigger asChild>
+ <Button variant="ghost" className="h-8 w-8 p-0">
+ <span className="sr-only">메뉴 열기</span>
+ <AlertTriangle className="h-4 w-4" />
+ </Button>
+ </DropdownMenuTrigger>
+ <DropdownMenuContent align="end">
+ <DropdownMenuItem onClick={() => setRowAction({ row, type: "view" })}>
+ <Eye className="mr-2 h-4 w-4" />
+ 상세보기
+ </DropdownMenuItem>
+ {row.original.status === 'bidding_closed' && (
+ <>
+ <DropdownMenuSeparator />
+ <DropdownMenuItem onClick={() => setRowAction({ row, type: "open_bidding" })}>
+ <Calendar className="mr-2 h-4 w-4" />
+ 개찰하기
+ </DropdownMenuItem>
+ </>
+ )}
+ </DropdownMenuContent>
+ </DropdownMenu>
+ ),
+ size: 50,
+ enableSorting: false,
+ enableHiding: false,
+ },
+ ]
+}
diff --git a/lib/bidding/receive/biddings-receive-table.tsx b/lib/bidding/receive/biddings-receive-table.tsx
new file mode 100644
index 00000000..88fade40
--- /dev/null
+++ b/lib/bidding/receive/biddings-receive-table.tsx
@@ -0,0 +1,211 @@
+"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 { getBiddingsReceiveColumns } from "./biddings-receive-columns"
+import { getBiddingsForReceive } from "@/lib/bidding/service"
+import {
+ biddingStatusLabels,
+ contractTypeLabels,
+} from "@/db/schema"
+import { SpecificationMeetingDialog, PrDocumentsDialog } from "../list/bidding-detail-dialogs"
+
+type BiddingReceiveItem = {
+ id: number
+ biddingNumber: string
+ originalBiddingNumber: string | null
+ title: string
+ status: string
+ contractType: string
+ prNumber: string | null
+ submissionStartDate: Date | null
+ submissionEndDate: Date | null
+ bidPicName: string | null
+ supplyPicName: string | null
+ createdBy: string | null
+ createdAt: Date | null
+ updatedAt: Date | null
+
+ // 참여 현황
+ participantExpected: number
+ participantParticipated: number
+ participantDeclined: number
+ participantPending: number
+
+ // 개찰 정보
+ openedAt: Date | null
+ openedBy: string | null
+}
+
+interface BiddingsReceiveTableProps {
+ promises: Promise<
+ [
+ Awaited<ReturnType<typeof getBiddingsForReceive>>
+ ]
+ >
+}
+
+export function BiddingsReceiveTable({ promises }: BiddingsReceiveTableProps) {
+ const [biddingsResult] = React.use(promises)
+
+ // biddingsResult에서 data와 pageCount 추출
+ const { data, pageCount } = biddingsResult
+
+ const [isCompact, setIsCompact] = React.useState<boolean>(false)
+ const [specMeetingDialogOpen, setSpecMeetingDialogOpen] = React.useState(false)
+ const [prDocumentsDialogOpen, setPrDocumentsDialogOpen] = React.useState(false)
+ const [selectedBidding, setSelectedBidding] = React.useState<BiddingReceiveItem | null>(null)
+
+ const [rowAction, setRowAction] = React.useState<DataTableRowAction<BiddingReceiveItem> | null>(null)
+
+ const router = useRouter()
+ const { data: session } = useSession()
+
+ const columns = React.useMemo(
+ () => getBiddingsReceiveColumns({ 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 "open_bidding":
+ // 개찰하기 (추후 구현)
+ console.log('개찰하기:', rowAction.row.original)
+ break
+ default:
+ break
+ }
+ }
+ }, [rowAction])
+
+ const filterFields: DataTableFilterField<BiddingReceiveItem>[] = [
+ {
+ 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<BiddingReceiveItem>[] = [
+ { 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: "submissionStartDate", label: "제출시작일", type: "date" },
+ { id: "submissionEndDate", label: "제출마감일", type: "date" },
+ ]
+
+ const { table } = useDataTable({
+ data,
+ columns,
+ pageCount,
+ filterFields,
+ enablePinning: true,
+ enableAdvancedFilter: true,
+ initialState: {
+ sorting: [{ id: "createdAt", 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 (
+ <>
+ <DataTable
+ table={table}
+ compact={isCompact}
+ >
+ <DataTableAdvancedToolbar
+ table={table}
+ filterFields={advancedFilterFields}
+ shallow={false}
+ enableCompactToggle={true}
+ compactStorageKey="biddingsReceiveTableCompact"
+ onCompactChange={handleCompactChange}
+ >
+ </DataTableAdvancedToolbar>
+ </DataTable>
+
+ {/* 사양설명회 다이얼로그 */}
+ <SpecificationMeetingDialog
+ open={specMeetingDialogOpen}
+ onOpenChange={handleSpecMeetingDialogClose}
+ bidding={selectedBidding}
+ />
+
+ {/* PR 문서 다이얼로그 */}
+ <PrDocumentsDialog
+ open={prDocumentsDialogOpen}
+ onOpenChange={handlePrDocumentsDialogClose}
+ bidding={selectedBidding}
+ />
+ </>
+ )
+}
diff --git a/lib/bidding/selection/biddings-selection-columns.tsx b/lib/bidding/selection/biddings-selection-columns.tsx
new file mode 100644
index 00000000..bbcd2d77
--- /dev/null
+++ b/lib/bidding/selection/biddings-selection-columns.tsx
@@ -0,0 +1,289 @@
+"use client"
+
+import * as React from "react"
+import { type ColumnDef } from "@tanstack/react-table"
+import { Badge } from "@/components/ui/badge"
+import { Button } from "@/components/ui/button"
+import {
+ Eye, Calendar, FileText, DollarSign, TrendingUp, TrendingDown
+} from "lucide-react"
+import {
+ Tooltip,
+ TooltipContent,
+ TooltipProvider,
+ TooltipTrigger,
+} from "@/components/ui/tooltip"
+import {
+ DropdownMenu,
+ DropdownMenuContent,
+ DropdownMenuItem,
+ DropdownMenuSeparator,
+ DropdownMenuTrigger,
+} from "@/components/ui/dropdown-menu"
+import { DataTableColumnHeaderSimple } from "@/components/data-table/data-table-column-simple-header"
+import {
+ biddingStatusLabels,
+ contractTypeLabels,
+} from "@/db/schema"
+import { formatDate } from "@/lib/utils"
+import { DataTableRowAction } from "@/types/table"
+
+type BiddingSelectionItem = {
+ id: number
+ biddingNumber: string
+ originalBiddingNumber: string | null
+ title: string
+ status: string
+ contractType: string
+ prNumber: string | null
+ submissionStartDate: Date | null
+ submissionEndDate: Date | null
+ bidPicName: string | null
+ supplyPicName: string | null
+ createdBy: string | null
+ createdAt: Date | null
+ updatedAt: Date | null
+
+ // 입찰 결과 정보 (개찰 이후에만 의미 있음)
+ participantCount?: number
+ submittedCount?: number
+ avgBidPrice?: number | null
+ minBidPrice?: number | null
+ maxBidPrice?: number | null
+ targetPrice?: number | null
+ currency?: string | null
+}
+
+interface GetColumnsProps {
+ setRowAction: React.Dispatch<React.SetStateAction<DataTableRowAction<BiddingSelectionItem> | null>>
+}
+
+// 상태별 배지 색상
+const getStatusBadgeVariant = (status: string) => {
+ switch (status) {
+ case 'bidding_opened':
+ return 'default'
+ case 'bidding_closed':
+ return 'outline'
+ case 'evaluation_of_bidding':
+ return 'secondary'
+ case 'vendor_selected':
+ return 'default'
+ default:
+ return 'outline'
+ }
+}
+
+// 금액 포맷팅
+const formatCurrency = (amount: string | number | null, currency = 'KRW') => {
+ if (!amount) return '-'
+
+ const numAmount = typeof amount === 'string' ? parseFloat(amount) : amount
+ if (isNaN(numAmount)) return '-'
+
+ return new Intl.NumberFormat('ko-KR', {
+ style: 'currency',
+ currency: currency,
+ minimumFractionDigits: 0,
+ maximumFractionDigits: 0,
+ }).format(numAmount)
+}
+
+export function getBiddingsSelectionColumns({ setRowAction }: GetColumnsProps): ColumnDef<BiddingSelectionItem>[] {
+
+ return [
+ // ░░░ 입찰번호 ░░░
+ {
+ accessorKey: "biddingNumber",
+ header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="입찰번호" />,
+ cell: ({ row }) => (
+ <div className="font-mono text-sm">
+ {row.original.biddingNumber}
+ </div>
+ ),
+ size: 120,
+ meta: { excelHeader: "입찰번호" },
+ },
+
+ // ░░░ 입찰명 ░░░
+ {
+ accessorKey: "title",
+ header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="입찰명" />,
+ cell: ({ row }) => (
+ <div className="truncate max-w-[200px]" title={row.original.title}>
+ <Button
+ variant="link"
+ className="p-0 h-auto text-left justify-start font-bold underline"
+ onClick={() => setRowAction({ row, type: "view" })}
+ >
+ <div className="whitespace-pre-line">
+ {row.original.title}
+ </div>
+ </Button>
+ </div>
+ ),
+ size: 200,
+ meta: { excelHeader: "입찰명" },
+ },
+
+ // ░░░ 원입찰번호 ░░░
+ {
+ accessorKey: "originalBiddingNumber",
+ header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="원입찰번호" />,
+ cell: ({ row }) => (
+ <div className="font-mono text-sm">
+ {row.original.originalBiddingNumber || '-'}
+ </div>
+ ),
+ size: 120,
+ meta: { excelHeader: "원입찰번호" },
+ },
+
+ // ░░░ 진행상태 ░░░
+ {
+ accessorKey: "status",
+ header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="진행상태" />,
+ cell: ({ row }) => (
+ <Badge variant={getStatusBadgeVariant(row.original.status)}>
+ {biddingStatusLabels[row.original.status]}
+ </Badge>
+ ),
+ size: 120,
+ meta: { excelHeader: "진행상태" },
+ },
+
+ // ░░░ 계약구분 ░░░
+ {
+ accessorKey: "contractType",
+ header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="계약구분" />,
+ cell: ({ row }) => (
+ <Badge variant="outline">
+ {contractTypeLabels[row.original.contractType]}
+ </Badge>
+ ),
+ size: 100,
+ meta: { excelHeader: "계약구분" },
+ },
+
+ // ░░░ 입찰제출기간 ░░░
+ {
+ id: "submissionPeriod",
+ header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="입찰제출기간" />,
+ cell: ({ row }) => {
+ const startDate = row.original.submissionStartDate
+ const endDate = row.original.submissionEndDate
+
+ if (!startDate || !endDate) return <span className="text-muted-foreground">-</span>
+
+ const now = new Date()
+ const isPast = now > new Date(endDate)
+ const isClosed = isPast
+
+ return (
+ <div className="text-xs">
+ <div className={`${isClosed ? 'text-red-600' : 'text-gray-600'}`}>
+ {formatDate(startDate, "KR")} ~ {formatDate(endDate, "KR")}
+ </div>
+ {isClosed && (
+ <Badge variant="destructive" className="text-xs mt-1">마감</Badge>
+ )}
+ </div>
+ )
+ },
+ size: 140,
+ meta: { excelHeader: "입찰제출기간" },
+ },
+
+ // ░░░ 입찰담당자 ░░░
+ {
+ accessorKey: "bidPicName",
+ header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="입찰담당자" />,
+ cell: ({ row }) => {
+ const bidPic = row.original.bidPicName
+ const supplyPic = row.original.supplyPicName
+
+ const displayName = bidPic || supplyPic || "-"
+ return <span className="text-sm">{displayName}</span>
+ },
+ size: 100,
+ meta: { excelHeader: "입찰담당자" },
+ },
+
+ // ░░░ P/R번호 ░░░
+ {
+ accessorKey: "prNumber",
+ header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="P/R번호" />,
+ cell: ({ row }) => (
+ <span className="font-mono text-sm">{row.original.prNumber || '-'}</span>
+ ),
+ size: 100,
+ meta: { excelHeader: "P/R번호" },
+ },
+
+ // ░░░ 참여업체수 ░░░
+ {
+ id: "participantCount",
+ header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="참여업체수" />,
+ cell: ({ row }) => {
+ const count = row.original.participantCount || 0
+ return (
+ <div className="flex items-center gap-1">
+ <span className="text-sm font-medium">{count}</span>
+ </div>
+ )
+ },
+ size: 100,
+ meta: { excelHeader: "참여업체수" },
+ },
+
+
+ // ═══════════════════════════════════════════════════════════════
+ // 액션
+ // ═══════════════════════════════════════════════════════════════
+ {
+ id: "actions",
+ header: "액션",
+ cell: ({ row }) => (
+ <DropdownMenu>
+ <DropdownMenuTrigger asChild>
+ <Button variant="ghost" className="h-8 w-8 p-0">
+ <span className="sr-only">메뉴 열기</span>
+ <FileText className="h-4 w-4" />
+ </Button>
+ </DropdownMenuTrigger>
+ <DropdownMenuContent align="end">
+ <DropdownMenuItem onClick={() => setRowAction({ row, type: "view" })}>
+ <Eye className="mr-2 h-4 w-4" />
+ 상세보기
+ </DropdownMenuItem>
+ <DropdownMenuItem onClick={() => setRowAction({ row, type: "detail" })}>
+ <FileText className="mr-2 h-4 w-4" />
+ 상세분석
+ </DropdownMenuItem>
+ {row.original.status === 'bidding_opened' && (
+ <>
+ <DropdownMenuSeparator />
+ <DropdownMenuItem onClick={() => setRowAction({ row, type: "close_bidding" })}>
+ <Calendar className="mr-2 h-4 w-4" />
+ 입찰마감
+ </DropdownMenuItem>
+ </>
+ )}
+ {row.original.status === 'bidding_closed' && (
+ <>
+ <DropdownMenuSeparator />
+ <DropdownMenuItem onClick={() => setRowAction({ row, type: "evaluate_bidding" })}>
+ <DollarSign className="mr-2 h-4 w-4" />
+ 평가하기
+ </DropdownMenuItem>
+ </>
+ )}
+ </DropdownMenuContent>
+ </DropdownMenu>
+ ),
+ size: 50,
+ enableSorting: false,
+ enableHiding: false,
+ },
+ ]
+}
diff --git a/lib/bidding/selection/biddings-selection-table.tsx b/lib/bidding/selection/biddings-selection-table.tsx
new file mode 100644
index 00000000..912a7154
--- /dev/null
+++ b/lib/bidding/selection/biddings-selection-table.tsx
@@ -0,0 +1,218 @@
+"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 { getBiddingsSelectionColumns } from "./biddings-selection-columns"
+import { getBiddingsForSelection } from "@/lib/bidding/service"
+import {
+ biddingStatusLabels,
+ contractTypeLabels,
+} from "@/db/schema"
+import { SpecificationMeetingDialog, PrDocumentsDialog } from "../list/bidding-detail-dialogs"
+
+type BiddingSelectionItem = {
+ id: number
+ biddingNumber: string
+ originalBiddingNumber: string | null
+ title: string
+ status: string
+ contractType: string
+ prNumber: string | null
+ submissionStartDate: Date | null
+ submissionEndDate: Date | null
+ bidPicName: string | null
+ supplyPicName: string | null
+ createdBy: string | null
+ createdAt: Date | null
+ updatedAt: Date | null
+
+ // 입찰 결과 정보 (개찰 이후에만 의미 있음)
+ participantCount?: number
+ submittedCount?: number
+ avgBidPrice?: number | null
+ minBidPrice?: number | null
+ maxBidPrice?: number | null
+ targetPrice?: number | null
+ currency?: string | null
+}
+
+interface BiddingsSelectionTableProps {
+ promises: Promise<
+ [
+ Awaited<ReturnType<typeof getBiddingsForSelection>>
+ ]
+ >
+}
+
+export function BiddingsSelectionTable({ promises }: BiddingsSelectionTableProps) {
+ const [biddingsResult] = React.use(promises)
+
+ // biddingsResult에서 data와 pageCount 추출
+ const { data, pageCount } = biddingsResult
+
+ const [isCompact, setIsCompact] = React.useState<boolean>(false)
+ const [specMeetingDialogOpen, setSpecMeetingDialogOpen] = React.useState(false)
+ const [prDocumentsDialogOpen, setPrDocumentsDialogOpen] = React.useState(false)
+ const [selectedBidding, setSelectedBidding] = React.useState<BiddingSelectionItem | null>(null)
+
+ const [rowAction, setRowAction] = React.useState<DataTableRowAction<BiddingSelectionItem> | null>(null)
+
+ const router = useRouter()
+ const { data: session } = useSession()
+
+ const columns = React.useMemo(
+ () => getBiddingsSelectionColumns({ 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 "detail":
+ // 상세분석 페이지로 이동 (추후 구현)
+ router.push(`/evcp/bid-selection/${rowAction.row.original.id}/detail`)
+ break
+ case "close_bidding":
+ // 입찰마감 (추후 구현)
+ console.log('입찰마감:', rowAction.row.original)
+ break
+ case "evaluate_bidding":
+ // 평가하기 (추후 구현)
+ console.log('평가하기:', rowAction.row.original)
+ break
+ default:
+ break
+ }
+ }
+ }, [rowAction])
+
+ const filterFields: DataTableFilterField<BiddingSelectionItem>[] = [
+ {
+ 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<BiddingSelectionItem>[] = [
+ { 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: "submissionStartDate", label: "제출시작일", type: "date" },
+ { id: "submissionEndDate", label: "제출마감일", type: "date" },
+ ]
+
+ const { table } = useDataTable({
+ data,
+ columns,
+ pageCount,
+ filterFields,
+ enablePinning: true,
+ enableAdvancedFilter: true,
+ initialState: {
+ sorting: [{ id: "createdAt", 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 (
+ <>
+ <DataTable
+ table={table}
+ compact={isCompact}
+ >
+ <DataTableAdvancedToolbar
+ table={table}
+ filterFields={advancedFilterFields}
+ shallow={false}
+ enableCompactToggle={true}
+ compactStorageKey="biddingsSelectionTableCompact"
+ onCompactChange={handleCompactChange}
+ >
+ </DataTableAdvancedToolbar>
+ </DataTable>
+
+ {/* 사양설명회 다이얼로그 */}
+ <SpecificationMeetingDialog
+ open={specMeetingDialogOpen}
+ onOpenChange={handleSpecMeetingDialogClose}
+ bidding={selectedBidding}
+ />
+
+ {/* PR 문서 다이얼로그 */}
+ <PrDocumentsDialog
+ open={prDocumentsDialogOpen}
+ onOpenChange={handlePrDocumentsDialogClose}
+ bidding={selectedBidding}
+ />
+ </>
+ )
+}
diff --git a/lib/bidding/service.ts b/lib/bidding/service.ts
index 5ab18ef1..80e4850f 100644
--- a/lib/bidding/service.ts
+++ b/lib/bidding/service.ts
@@ -3,7 +3,6 @@
import db from '@/db/db'
import {
biddings,
- biddingListView,
biddingNoticeTemplate,
projects,
biddingDocuments,
@@ -14,7 +13,10 @@ import {
users,
basicContractTemplates,
vendorsWithTypesView,
- biddingCompanies
+ biddingCompanies,
+ biddingCompaniesContacts,
+ vendorContacts,
+ vendors
} from '@/db/schema'
import {
eq,
@@ -69,13 +71,40 @@ async function getUserNameById(userId: string): Promise<string> {
}
-export async function getBiddingNoticeTemplate() {
+export async function getBiddingNoticeTemplates() {
try {
const result = await db
.select()
.from(biddingNoticeTemplate)
- .where(eq(biddingNoticeTemplate.type, 'standard'))
- .limit(1)
+ .where(eq(biddingNoticeTemplate.isTemplate, true))
+ .orderBy(desc(biddingNoticeTemplate.updatedAt))
+
+ // 타입별로 그룹화하여 반환
+ const templates = result.reduce((acc, template) => {
+ acc[template.type] = template
+ return acc
+ }, {} as Record<string, typeof result[0]>)
+
+ return templates
+ } catch (error) {
+ console.error('Failed to get bidding notice templates:', error)
+ throw new Error('입찰공고문 템플릿을 불러오는데 실패했습니다.')
+ }
+}
+
+export async function getBiddingNoticeTemplate(type?: string) {
+ try {
+ let query = db
+ .select()
+ .from(biddingNoticeTemplate)
+ .where(eq(biddingNoticeTemplate.isTemplate, true))
+ .orderBy(desc(biddingNoticeTemplate.updatedAt))
+
+ if (type) {
+ query = query.where(eq(biddingNoticeTemplate.type, type))
+ }
+
+ const result = await query.limit(1)
return result[0] || null
} catch (error) {
@@ -84,18 +113,41 @@ export async function getBiddingNoticeTemplate() {
}
}
-export async function saveBiddingNoticeTemplate(formData: {
+export async function getBiddingNotice(biddingId: number) {
+ try {
+ const result = await db
+ .select({
+ id: biddingNoticeTemplate.id,
+ biddingId: biddingNoticeTemplate.biddingId,
+ title: biddingNoticeTemplate.title,
+ content: biddingNoticeTemplate.content,
+ isTemplate: biddingNoticeTemplate.isTemplate,
+ createdAt: biddingNoticeTemplate.createdAt,
+ updatedAt: biddingNoticeTemplate.updatedAt,
+ })
+ .from(biddingNoticeTemplate)
+ .where(eq(biddingNoticeTemplate.biddingId, biddingId))
+ .limit(1)
+
+ return result[0] || null
+ } catch (error) {
+ console.error('Failed to get bidding notice:', error)
+ throw new Error('입찰공고문을 불러오는데 실패했습니다.')
+ }
+}
+
+export async function saveBiddingNotice(biddingId: number, formData: {
title: string
content: string
}) {
try {
const { title, content } = formData
- // 기존 템플릿 확인
+ // 기존 입찰공고 확인
const existing = await db
.select()
.from(biddingNoticeTemplate)
- .where(eq(biddingNoticeTemplate.type, 'standard'))
+ .where(eq(biddingNoticeTemplate.biddingId, biddingId))
.limit(1)
if (existing.length > 0) {
@@ -107,13 +159,62 @@ export async function saveBiddingNoticeTemplate(formData: {
content,
updatedAt: new Date(),
})
- .where(eq(biddingNoticeTemplate.type, 'standard'))
+ .where(eq(biddingNoticeTemplate.biddingId, biddingId))
} else {
// 새로 생성
await db.insert(biddingNoticeTemplate).values({
- type: 'standard',
+ biddingId,
title,
content,
+ isTemplate: false,
+ })
+ }
+
+ return { success: true, message: '입찰공고문이 저장되었습니다.' }
+ } catch (error) {
+ console.error('Failed to save bidding notice:', error)
+ throw new Error('입찰공고문 저장에 실패했습니다.')
+ }
+}
+
+export async function saveBiddingNoticeTemplate(formData: {
+ title: string
+ content: string
+ type: string
+}) {
+ try {
+ const { title, content, type } = formData
+
+ // 기존 동일 타입의 템플릿 확인
+ const existing = await db
+ .select()
+ .from(biddingNoticeTemplate)
+ .where(and(
+ eq(biddingNoticeTemplate.isTemplate, true),
+ eq(biddingNoticeTemplate.type, type)
+ ))
+ .limit(1)
+
+ if (existing.length > 0) {
+ // 업데이트
+ await db
+ .update(biddingNoticeTemplate)
+ .set({
+ title,
+ content,
+ updatedAt: new Date(),
+ })
+ .where(and(
+ eq(biddingNoticeTemplate.isTemplate, true),
+ eq(biddingNoticeTemplate.type, type)
+ ))
+ } else {
+ // 새로 생성
+ await db.insert(biddingNoticeTemplate).values({
+ title,
+ content,
+ type,
+ isTemplate: true,
})
}
@@ -137,7 +238,7 @@ export async function getBiddings(input: GetBiddingsSchema) {
let advancedWhere: SQL<unknown> | undefined = undefined
if (input.filters && input.filters.length > 0) {
advancedWhere = filterColumns({
- table: biddingListView,
+ table: biddings,
filters: input.filters,
joinOperator: input.joinOperator || 'and',
})
@@ -147,72 +248,83 @@ export async function getBiddings(input: GetBiddingsSchema) {
const basicConditions: SQL<unknown>[] = []
if (input.biddingNumber) {
- basicConditions.push(ilike(biddingListView.biddingNumber, `%${input.biddingNumber}%`))
+ basicConditions.push(ilike(biddings.biddingNumber, `%${input.biddingNumber}%`))
}
if (input.status && input.status.length > 0) {
basicConditions.push(
- or(...input.status.map(status => eq(biddingListView.status, status)))!
+ or(...input.status.map(status => eq(biddings.status, status)))!
)
}
if (input.biddingType && input.biddingType.length > 0) {
basicConditions.push(
- or(...input.biddingType.map(type => eq(biddingListView.biddingType, type)))!
+ or(...input.biddingType.map(type => eq(biddings.biddingType, type)))!
)
}
if (input.contractType && input.contractType.length > 0) {
basicConditions.push(
- or(...input.contractType.map(type => eq(biddingListView.contractType, type)))!
+ or(...input.contractType.map(type => eq(biddings.contractType, type)))!
)
}
+ if (input.purchasingOrganization) {
+ basicConditions.push(ilike(biddings.purchasingOrganization, `%${input.purchasingOrganization}%`))
+ }
+
+ // 담당자 필터 (bidPicId 또는 supplyPicId로 검색)
if (input.managerName) {
- basicConditions.push(ilike(biddingListView.managerName, `%${input.managerName}%`))
+ // managerName으로 검색 시 bidPic 또는 supplyPic의 이름으로 검색
+ basicConditions.push(
+ or(
+ ilike(biddings.bidPicName, `%${input.managerName}%`),
+ ilike(biddings.supplyPicName, `%${input.managerName}%`)
+ )!
+ )
}
// 날짜 필터들
if (input.preQuoteDateFrom) {
- basicConditions.push(gte(biddingListView.preQuoteDate, input.preQuoteDateFrom))
+ basicConditions.push(gte(biddings.preQuoteDate, input.preQuoteDateFrom))
}
if (input.preQuoteDateTo) {
- basicConditions.push(lte(biddingListView.preQuoteDate, input.preQuoteDateTo))
+ basicConditions.push(lte(biddings.preQuoteDate, input.preQuoteDateTo))
}
if (input.submissionDateFrom) {
- basicConditions.push(gte(biddingListView.submissionStartDate, new Date(input.submissionDateFrom)))
+ basicConditions.push(gte(biddings.submissionStartDate, new Date(input.submissionDateFrom)))
}
if (input.submissionDateTo) {
- basicConditions.push(lte(biddingListView.submissionEndDate, new Date(input.submissionDateTo)))
+ basicConditions.push(lte(biddings.submissionEndDate, new Date(input.submissionDateTo)))
}
if (input.createdAtFrom) {
- basicConditions.push(gte(biddingListView.createdAt, new Date(input.createdAtFrom)))
+ basicConditions.push(gte(biddings.createdAt, new Date(input.createdAtFrom)))
}
if (input.createdAtTo) {
- basicConditions.push(lte(biddingListView.createdAt, new Date(input.createdAtTo)))
+ basicConditions.push(lte(biddings.createdAt, new Date(input.createdAtTo)))
}
// 가격 범위 필터
if (input.budgetMin) {
- basicConditions.push(gte(biddingListView.budget, input.budgetMin))
+ basicConditions.push(gte(biddings.budget, input.budgetMin))
}
if (input.budgetMax) {
- basicConditions.push(lte(biddingListView.budget, input.budgetMax))
+ basicConditions.push(lte(biddings.budget, input.budgetMax))
}
// Boolean 필터
if (input.hasSpecificationMeeting === "true") {
- basicConditions.push(eq(biddingListView.hasSpecificationMeeting, true))
+ basicConditions.push(eq(biddings.hasSpecificationMeeting, true))
} else if (input.hasSpecificationMeeting === "false") {
- basicConditions.push(eq(biddingListView.hasSpecificationMeeting, false))
+ basicConditions.push(eq(biddings.hasSpecificationMeeting, false))
}
if (input.hasPrDocument === "true") {
- basicConditions.push(eq(biddingListView.hasPrDocument, true))
+ basicConditions.push(eq(biddings.hasPrDocument, true))
} else if (input.hasPrDocument === "false") {
- basicConditions.push(eq(biddingListView.hasPrDocument, false))
+ basicConditions.push(eq(biddings.hasPrDocument, false))
}
const basicWhere = basicConditions.length > 0 ? and(...basicConditions) : undefined
@@ -222,13 +334,15 @@ export async function getBiddings(input: GetBiddingsSchema) {
if (input.search) {
const s = `%${input.search}%`
const searchConditions = [
- ilike(biddingListView.biddingNumber, s),
- ilike(biddingListView.title, s),
- ilike(biddingListView.projectName, s),
- ilike(biddingListView.itemName, s),
- ilike(biddingListView.managerName, s),
- ilike(biddingListView.prNumber, s),
- ilike(biddingListView.remarks, s),
+ ilike(biddings.biddingNumber, s),
+ ilike(biddings.title, s),
+ ilike(biddings.projectName, s),
+ ilike(biddings.itemName, s),
+ ilike(biddings.purchasingOrganization, s),
+ ilike(biddings.bidPicName, s),
+ ilike(biddings.supplyPicName, s),
+ ilike(biddings.prNumber, s),
+ ilike(biddings.remarks, s),
]
globalWhere = or(...searchConditions)
}
@@ -244,7 +358,7 @@ export async function getBiddings(input: GetBiddingsSchema) {
// ✅ 5) 전체 개수 조회
const totalResult = await db
.select({ count: count() })
- .from(biddingListView)
+ .from(biddings)
.where(finalWhere)
const total = totalResult[0]?.count || 0
@@ -257,18 +371,444 @@ export async function getBiddings(input: GetBiddingsSchema) {
// ✅ 6) 정렬 및 페이징
const orderByColumns = input.sort.map((sort) => {
- const column = sort.id as keyof typeof biddingListView.$inferSelect
- return sort.desc ? desc(biddingListView[column]) : asc(biddingListView[column])
+ const column = sort.id as keyof typeof biddings.$inferSelect
+ return sort.desc ? desc(biddings[column]) : asc(biddings[column])
})
if (orderByColumns.length === 0) {
- orderByColumns.push(desc(biddingListView.createdAt))
+ orderByColumns.push(desc(biddings.createdAt))
}
- // ✅ 7) 메인 쿼리 - 매우 간단해짐!
+ // ✅ 7) 메인 쿼리 - 이제 조인이 필요함!
const data = await db
- .select()
- .from(biddingListView)
+ .select({
+ // 기본 입찰 정보
+ id: biddings.id,
+ biddingNumber: biddings.biddingNumber,
+ originalBiddingNumber: biddings.originalBiddingNumber,
+ revision: biddings.revision,
+ projectName: biddings.projectName,
+ itemName: biddings.itemName,
+ title: biddings.title,
+ description: biddings.description,
+ biddingSourceType: biddings.biddingSourceType,
+ isUrgent: biddings.isUrgent,
+
+ // 계약 정보
+ contractType: biddings.contractType,
+ biddingType: biddings.biddingType,
+ awardCount: biddings.awardCount,
+ contractStartDate: biddings.contractStartDate,
+ contractEndDate: biddings.contractEndDate,
+
+ // 일정 관리
+ preQuoteDate: biddings.preQuoteDate,
+ biddingRegistrationDate: biddings.biddingRegistrationDate,
+ submissionStartDate: biddings.submissionStartDate,
+ submissionEndDate: biddings.submissionEndDate,
+ evaluationDate: biddings.evaluationDate,
+
+ // 회의 및 문서
+ hasSpecificationMeeting: biddings.hasSpecificationMeeting,
+ hasPrDocument: biddings.hasPrDocument,
+ prNumber: biddings.prNumber,
+
+ // 가격 정보
+ currency: biddings.currency,
+ budget: biddings.budget,
+ targetPrice: biddings.targetPrice,
+ finalBidPrice: biddings.finalBidPrice,
+
+ // 상태 및 담당자
+ status: biddings.status,
+ isPublic: biddings.isPublic,
+ purchasingOrganization: biddings.purchasingOrganization,
+ bidPicId: biddings.bidPicId,
+ bidPicName: biddings.bidPicName,
+ supplyPicId: biddings.supplyPicId,
+ supplyPicName: biddings.supplyPicName,
+
+ // 메타 정보
+ remarks: biddings.remarks,
+ createdBy: biddings.createdBy,
+ createdAt: biddings.createdAt,
+ updatedAt: biddings.updatedAt,
+ updatedBy: biddings.updatedBy,
+
+ // 사양설명회 상세 정보
+ hasSpecificationMeetingDetails: sql<boolean>`${specificationMeetings.id} IS NOT NULL`.as('has_specification_meeting_details'),
+ meetingDate: specificationMeetings.meetingDate,
+ meetingLocation: specificationMeetings.location,
+ meetingContactPerson: specificationMeetings.contactPerson,
+ meetingIsRequired: specificationMeetings.isRequired,
+
+ // PR 문서 집계
+ prDocumentCount: sql<number>`
+ COALESCE((
+ SELECT count(*)
+ FROM pr_documents
+ WHERE bidding_id = ${biddings.id}
+ ), 0)
+ `.as('pr_document_count'),
+
+ prDocumentNames: sql<string[]>`
+ (
+ SELECT array_agg(document_name ORDER BY registered_at DESC)
+ FROM pr_documents
+ WHERE bidding_id = ${biddings.id}
+ LIMIT 5
+ )
+ `.as('pr_document_names'),
+
+ // 참여 현황 집계 (전체)
+ participantExpected: sql<number>`
+ COALESCE((
+ SELECT count(*)
+ FROM bidding_companies
+ WHERE bidding_id = ${biddings.id}
+ ), 0)
+ `.as('participant_expected'),
+
+ // === 사전견적 참여 현황 ===
+ preQuotePending: sql<number>`
+ COALESCE((
+ SELECT count(*)
+ FROM bidding_companies
+ WHERE bidding_id = ${biddings.id}
+ AND invitation_status IN ('pending', 'pre_quote_sent')
+ ), 0)
+ `.as('pre_quote_pending'),
+
+ preQuoteAccepted: sql<number>`
+ COALESCE((
+ SELECT count(*)
+ FROM bidding_companies
+ WHERE bidding_id = ${biddings.id}
+ AND invitation_status = 'pre_quote_accepted'
+ ), 0)
+ `.as('pre_quote_accepted'),
+
+ preQuoteDeclined: sql<number>`
+ COALESCE((
+ SELECT count(*)
+ FROM bidding_companies
+ WHERE bidding_id = ${biddings.id}
+ AND invitation_status = 'pre_quote_declined'
+ ), 0)
+ `.as('pre_quote_declined'),
+
+ preQuoteSubmitted: sql<number>`
+ COALESCE((
+ SELECT count(*)
+ FROM bidding_companies
+ WHERE bidding_id = ${biddings.id}
+ AND invitation_status = 'pre_quote_submitted'
+ ), 0)
+ `.as('pre_quote_submitted'),
+
+ // === 입찰 참여 현황 ===
+ biddingPending: sql<number>`
+ COALESCE((
+ SELECT count(*)
+ FROM bidding_companies
+ WHERE bidding_id = ${biddings.id}
+ AND invitation_status = 'bidding_sent'
+ ), 0)
+ `.as('bidding_pending'),
+
+ biddingAccepted: sql<number>`
+ COALESCE((
+ SELECT count(*)
+ FROM bidding_companies
+ WHERE bidding_id = ${biddings.id}
+ AND invitation_status = 'bidding_accepted'
+ ), 0)
+ `.as('bidding_accepted'),
+
+ biddingDeclined: sql<number>`
+ COALESCE((
+ SELECT count(*)
+ FROM bidding_companies
+ WHERE bidding_id = ${biddings.id}
+ AND invitation_status = 'bidding_declined'
+ ), 0)
+ `.as('bidding_declined'),
+
+ biddingCancelled: sql<number>`
+ COALESCE((
+ SELECT count(*)
+ FROM bidding_companies
+ WHERE bidding_id = ${biddings.id}
+ AND invitation_status = 'bidding_cancelled'
+ ), 0)
+ `.as('bidding_cancelled'),
+
+ biddingSubmitted: sql<number>`
+ COALESCE((
+ SELECT count(*)
+ FROM bidding_companies
+ WHERE bidding_id = ${biddings.id}
+ AND invitation_status = 'bidding_submitted'
+ ), 0)
+ `.as('bidding_submitted'),
+
+ // === 호환성을 위한 기존 컬럼 (사전견적 기준) ===
+ participantParticipated: sql<number>`
+ COALESCE((
+ SELECT count(*)
+ FROM bidding_companies
+ WHERE bidding_id = ${biddings.id}
+ AND invitation_status = 'pre_quote_submitted'
+ ), 0)
+ `.as('participant_participated'),
+
+ participantDeclined: sql<number>`
+ COALESCE((
+ SELECT count(*)
+ FROM bidding_companies
+ WHERE bidding_id = ${biddings.id}
+ AND invitation_status IN ('pre_quote_declined', 'bidding_declined')
+ ), 0)
+ `.as('participant_declined'),
+
+ participantPending: sql<number>`
+ COALESCE((
+ SELECT count(*)
+ FROM bidding_companies
+ WHERE bidding_id = ${biddings.id}
+ AND invitation_status IN ('pending', 'pre_quote_sent', 'bidding_sent')
+ ), 0)
+ `.as('participant_pending'),
+
+ participantAccepted: sql<number>`
+ COALESCE((
+ SELECT count(*)
+ FROM bidding_companies
+ WHERE bidding_id = ${biddings.id}
+ AND invitation_status IN ('pre_quote_accepted', 'bidding_accepted')
+ ), 0)
+ `.as('participant_accepted'),
+
+ // 참여율 계산 (입찰 기준 - 응찰 완료 / 전체)
+ participationRate: sql<number>`
+ CASE
+ WHEN (
+ SELECT count(*)
+ FROM bidding_companies
+ WHERE bidding_id = ${biddings.id}
+ ) > 0
+ THEN ROUND(
+ (
+ SELECT count(*)::decimal
+ FROM bidding_companies
+ WHERE bidding_id = ${biddings.id}
+ AND invitation_status = 'bidding_submitted'
+ ) / (
+ SELECT count(*)::decimal
+ FROM bidding_companies
+ WHERE bidding_id = ${biddings.id}
+ ) * 100, 1
+ )
+ ELSE 0
+ END
+ `.as('participation_rate'),
+
+ // 견적 금액 통계
+ avgPreQuoteAmount: sql<number>`
+ (
+ SELECT AVG(pre_quote_amount)
+ FROM bidding_companies
+ WHERE bidding_id = ${biddings.id}
+ AND pre_quote_amount IS NOT NULL
+ )
+ `.as('avg_pre_quote_amount'),
+
+ minPreQuoteAmount: sql<number>`
+ (
+ SELECT MIN(pre_quote_amount)
+ FROM bidding_companies
+ WHERE bidding_id = ${biddings.id}
+ AND pre_quote_amount IS NOT NULL
+ )
+ `.as('min_pre_quote_amount'),
+
+ maxPreQuoteAmount: sql<number>`
+ (
+ SELECT MAX(pre_quote_amount)
+ FROM bidding_companies
+ WHERE bidding_id = ${biddings.id}
+ AND pre_quote_amount IS NOT NULL
+ )
+ `.as('max_pre_quote_amount'),
+
+ avgFinalQuoteAmount: sql<number>`
+ (
+ SELECT AVG(final_quote_amount)
+ FROM bidding_companies
+ WHERE bidding_id = ${biddings.id}
+ AND final_quote_amount IS NOT NULL
+ )
+ `.as('avg_final_quote_amount'),
+
+ minFinalQuoteAmount: sql<number>`
+ (
+ SELECT MIN(final_quote_amount)
+ FROM bidding_companies
+ WHERE bidding_id = ${biddings.id}
+ AND final_quote_amount IS NOT NULL
+ )
+ `.as('min_final_quote_amount'),
+
+ maxFinalQuoteAmount: sql<number>`
+ (
+ SELECT MAX(final_quote_amount)
+ FROM bidding_companies
+ WHERE bidding_id = ${biddings.id}
+ AND final_quote_amount IS NOT NULL
+ )
+ `.as('max_final_quote_amount'),
+
+ // 선정 및 낙찰 정보
+ selectedForFinalBidCount: sql<number>`
+ COALESCE((
+ SELECT count(*)
+ FROM bidding_companies
+ WHERE bidding_id = ${biddings.id}
+ AND is_pre_quote_selected = true
+ ), 0)
+ `.as('selected_for_final_bid_count'),
+
+ winnerCount: sql<number>`
+ COALESCE((
+ SELECT count(*)
+ FROM bidding_companies
+ WHERE bidding_id = ${biddings.id}
+ AND is_winner = true
+ ), 0)
+ `.as('winner_count'),
+
+ winnerCompanyNames: sql<string[]>`
+ (
+ SELECT array_agg(v.vendor_name ORDER BY v.vendor_name)
+ FROM bidding_companies bc
+ JOIN vendors v ON bc.company_id = v.id
+ WHERE bc.bidding_id = ${biddings.id}
+ AND bc.is_winner = true
+ )
+ `.as('winner_company_names'),
+
+ // 일정 상태 계산
+ submissionStatus: sql<string>`
+ CASE
+ WHEN ${biddings.submissionStartDate} IS NULL OR ${biddings.submissionEndDate} IS NULL
+ THEN 'not_scheduled'
+ WHEN NOW() < ${biddings.submissionStartDate}
+ THEN 'scheduled'
+ WHEN NOW() BETWEEN ${biddings.submissionStartDate} AND ${biddings.submissionEndDate}
+ THEN 'active'
+ WHEN NOW() > ${biddings.submissionEndDate}
+ THEN 'closed'
+ ELSE 'unknown'
+ END
+ `.as('submission_status'),
+
+ // 마감까지 남은 일수
+ daysUntilDeadline: sql<number>`
+ CASE
+ WHEN ${biddings.submissionEndDate} IS NOT NULL
+ AND NOW() < ${biddings.submissionEndDate}
+ THEN EXTRACT(DAYS FROM (${biddings.submissionEndDate} - NOW()))::integer
+ ELSE NULL
+ END
+ `.as('days_until_deadline'),
+
+ // 시작까지 남은 일수
+ daysUntilStart: sql<number>`
+ CASE
+ WHEN ${biddings.submissionStartDate} IS NOT NULL
+ AND NOW() < ${biddings.submissionStartDate}
+ THEN EXTRACT(DAYS FROM (${biddings.submissionStartDate} - NOW()))::integer
+ ELSE NULL
+ END
+ `.as('days_until_start'),
+
+ // 예산 대비 최저 견적 비율
+ budgetEfficiencyRate: sql<number>`
+ CASE
+ WHEN ${biddings.budget} IS NOT NULL AND ${biddings.budget} > 0
+ AND (
+ SELECT MIN(final_quote_amount)
+ FROM bidding_companies
+ WHERE bidding_id = ${biddings.id}
+ AND final_quote_amount IS NOT NULL
+ ) IS NOT NULL
+ THEN ROUND(
+ (
+ SELECT MIN(final_quote_amount)
+ FROM bidding_companies
+ WHERE bidding_id = ${biddings.id}
+ AND final_quote_amount IS NOT NULL
+ ) / ${biddings.budget} * 100, 1
+ )
+ ELSE NULL
+ END
+ `.as('budget_efficiency_rate'),
+
+ // 내정가 대비 최저 견적 비율
+ targetPriceEfficiencyRate: sql<number>`
+ CASE
+ WHEN ${biddings.targetPrice} IS NOT NULL AND ${biddings.targetPrice} > 0
+ AND (
+ SELECT MIN(final_quote_amount)
+ FROM bidding_companies
+ WHERE bidding_id = ${biddings.id}
+ AND final_quote_amount IS NOT NULL
+ ) IS NOT NULL
+ THEN ROUND(
+ (
+ SELECT MIN(final_quote_amount)
+ FROM bidding_companies
+ WHERE bidding_id = ${biddings.id}
+ AND final_quote_amount IS NOT NULL
+ ) / ${biddings.targetPrice} * 100, 1
+ )
+ ELSE NULL
+ END
+ `.as('target_price_efficiency_rate'),
+
+ // 입찰 진행 단계 점수 (0-100)
+ progressScore: sql<number>`
+ CASE ${biddings.status}
+ WHEN 'bidding_generated' THEN 10
+ WHEN 'request_for_quotation' THEN 20
+ WHEN 'received_quotation' THEN 40
+ WHEN 'set_target_price' THEN 60
+ WHEN 'bidding_opened' THEN 70
+ WHEN 'bidding_closed' THEN 80
+ WHEN 'evaluation_of_bidding' THEN 90
+ WHEN 'vendor_selected' THEN 100
+ WHEN 'bidding_disposal' THEN 0
+ ELSE 0
+ END
+ `.as('progress_score'),
+
+ // 마지막 활동일 (가장 최근 업체 응답일)
+ lastActivityDate: sql<Date>`
+ GREATEST(
+ ${biddings.updatedAt},
+ COALESCE((
+ SELECT MAX(updated_at)
+ FROM bidding_companies
+ WHERE bidding_id = ${biddings.id}
+ ), ${biddings.updatedAt})
+ )
+ `.as('last_activity_date'),
+ })
+ .from(biddings)
+ .leftJoin(
+ specificationMeetings,
+ sql`${biddings.id} = ${specificationMeetings.biddingId}`
+ )
.where(finalWhere)
.orderBy(...orderByColumns)
.limit(input.perPage)
@@ -305,80 +845,6 @@ export async function getBiddingStatusCounts() {
}
}
-// 입찰유형별 개수 집계
-export async function getBiddingTypeCounts() {
- try {
- const counts = await db
- .select({
- biddingType: biddings.biddingType,
- count: count(),
- })
- .from(biddings)
- .groupBy(biddings.biddingType)
-
- return counts.reduce((acc, { biddingType, count }) => {
- acc[biddingType] = count
- return acc
- }, {} as Record<string, number>)
- } catch (error) {
- console.error('Failed to get bidding type counts:', error)
- return {}
- }
-}
-
-// 담당자별 개수 집계
-export async function getBiddingManagerCounts() {
- try {
- const counts = await db
- .select({
- managerName: biddings.managerName,
- count: count(),
- })
- .from(biddings)
- .where(sql`${biddings.managerName} IS NOT NULL AND ${biddings.managerName} != ''`)
- .groupBy(biddings.managerName)
-
- return counts.reduce((acc, { managerName, count }) => {
- if (managerName) {
- acc[managerName] = count
- }
- return acc
- }, {} as Record<string, number>)
- } catch (error) {
- console.error('Failed to get bidding manager counts:', error)
- return {}
- }
-}
-
-// 월별 입찰 생성 통계
-export async function getBiddingMonthlyStats(year: number = new Date().getFullYear()) {
- try {
- const stats = await db
- .select({
- month: sql<number>`EXTRACT(MONTH FROM ${biddings.createdAt})`.as('month'),
- count: count(),
- })
- .from(biddings)
- .where(sql`EXTRACT(YEAR FROM ${biddings.createdAt}) = ${year}`)
- .groupBy(sql`EXTRACT(MONTH FROM ${biddings.createdAt})`)
- .orderBy(sql`EXTRACT(MONTH FROM ${biddings.createdAt})`)
-
- // 1-12월 전체 배열 생성 (없는 월은 0으로)
- const monthlyData = Array.from({ length: 12 }, (_, i) => {
- const month = i + 1
- const found = stats.find(stat => stat.month === month)
- return {
- month,
- count: found?.count || 0,
- }
- })
-
- return monthlyData
- } catch (error) {
- console.error('Failed to get bidding monthly stats:', error)
- return []
- }
-}
export interface CreateBiddingInput extends CreateBiddingSchema {
// 사양설명회 정보 (선택사항)
@@ -401,17 +867,56 @@ export interface CreateBiddingInput extends CreateBiddingSchema {
prItems?: Array<{
id: string
prNumber: string
- itemCode: string
- itemInfo: string
+ projectId?: number
+ projectInfo?: string
+ shi?: string
quantity: string
quantityUnit: string
totalWeight: string
weightUnit: string
+ materialGroupNumber: string
+ materialGroupInfo: string
+ materialNumber?: string
+ materialInfo?: string
materialDescription: string
hasSpecDocument: boolean
requestedDeliveryDate: string
specFiles: File[]
isRepresentative: boolean
+
+ // 가격 정보
+ annualUnitPrice?: string
+ currency?: string
+
+ // 단위 정보
+ priceUnit?: string
+ purchaseUnit?: string
+ materialWeight?: string
+
+ // WBS 정보
+ wbsCode?: string
+ wbsName?: string
+
+ // Cost Center 정보
+ costCenterCode?: string
+ costCenterName?: string
+
+ // GL Account 정보
+ glAccountCode?: string
+ glAccountName?: string
+
+ // 내정 정보
+ targetUnitPrice?: string
+ targetAmount?: string
+ targetCurrency?: string
+
+ // 예산 정보
+ budgetAmount?: string
+ budgetCurrency?: string
+
+ // 실적 정보
+ actualAmount?: string
+ actualCurrency?: string
}>
// 입찰 조건 (선택사항)
@@ -419,9 +924,10 @@ export interface CreateBiddingInput extends CreateBiddingSchema {
paymentTerms: string
taxConditions: string
incoterms: string
- contractDeliveryDate: string
- shippingPort: string
- destinationPort: string
+ incotermsOption?: string
+ contractDeliveryDate?: string
+ shippingPort?: string
+ destinationPort?: string
isPriceAdjustmentApplicable: boolean
sparePartOptions: string
}
@@ -435,12 +941,54 @@ export interface UpdateBiddingInput extends UpdateBiddingSchema {
id: number
}
+// 4자리 시퀀스 생성 (0001 -> 0009 -> 000A -> 000Z -> 0011 -> ...)
+function generateNextSequence(currentMax: string | null): string {
+ if (!currentMax) {
+ return '0001'; // 첫 번째 시퀀스
+ }
+
+ // 36진수로 변환 (0-9, A-Z)
+ const chars = '0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ';
+
+ // 4자리 시퀀스를 36진수로 해석
+ let value = 0;
+ for (let i = 0; i < 4; i++) {
+ const charIndex = chars.indexOf(currentMax[i]);
+ if (charIndex === -1) return '0001'; // 잘못된 문자면 초기화
+ value = value * 36 + charIndex;
+ }
+
+ // 1 증가
+ value += 1;
+
+ // 다시 4자리 36진수로 변환
+ let result = '';
+ for (let i = 0; i < 4; i++) {
+ const remainder = value % 36;
+ result = chars[remainder] + result;
+ value = Math.floor(value / 36);
+ }
+
+ // 4자리가 되도록 앞에 0 채우기
+ return result.padStart(4, '0');
+}
+
// 자동 입찰번호 생성
export async function generateBiddingNumber(
+ contractType: string,
userId?: string,
tx?: any,
maxRetries: number = 5
): Promise<string> {
+ // 계약 타입별 접두사 설정
+ const typePrefix = {
+ 'general': 'E',
+ 'unit_price': 'F',
+ 'sale': 'G'
+ };
+
+ const prefix = typePrefix[contractType as keyof typeof typePrefix] || 'E';
+
// user 테이블의 user.userCode가 있으면 발주담당자 코드로 사용
// userId가 주어졌을 때 user.userCode를 조회, 없으면 '000' 사용
let purchaseManagerCode = '000';
@@ -458,27 +1006,25 @@ export async function generateBiddingNumber(
? purchaseManagerCode.substring(0, 3).toUpperCase()
: '000';
+ // 현재 년도 2자리
+ const currentYear = new Date().getFullYear().toString().slice(-2);
+
const dbInstance = tx || db;
- const prefix = `B${managerCode}`;
+ const yearPrefix = `${prefix}${managerCode}${currentYear}`;
for (let attempt = 0; attempt < maxRetries; attempt++) {
- // 현재 최대 일련번호 조회
+ // 현재 최대 시퀀스 조회 (년도별로, -01 제외하고 앞부분만)
+ const prefixLength = yearPrefix.length + 4;
const result = await dbInstance
- .select({
- maxNumber: sql<string>`MAX(${biddings.biddingNumber})`
+ .select({
+ maxNumber: sql<string>`MAX(LEFT(${biddings.biddingNumber}, ${prefixLength}))`
})
.from(biddings)
- .where(like(biddings.biddingNumber, `${prefix}%`));
+ .where(like(biddings.biddingNumber, `${yearPrefix}%`));
- let sequence = 1;
- if (result[0]?.maxNumber) {
- const lastSequence = parseInt(result[0].maxNumber.slice(-5));
- if (!isNaN(lastSequence)) {
- sequence = lastSequence + 1;
- }
- }
+ const nextSequence = generateNextSequence(result[0]?.maxNumber?.slice(-4) || null);
- const biddingNumber = `${prefix}${sequence.toString().padStart(5, '0')}`;
+ const biddingNumber = `${yearPrefix}${nextSequence}-01`;
// 중복 확인
const existing = await dbInstance
@@ -503,32 +1049,27 @@ export async function createBidding(input: CreateBiddingInput, userId: string) {
const userName = await getUserNameById(userId)
return await db.transaction(async (tx) => {
// 자동 입찰번호 생성
- const biddingNumber = await generateBiddingNumber(userId)
+ const biddingNumber = await generateBiddingNumber(input.contractType, userId, tx)
- // 프로젝트 정보 조회
+ // 프로젝트 정보 조회 (PR 아이템에서 설정됨)
let projectName = input.projectName
- if (input.projectId) {
- const project = await tx
- .select({ code: projects.code, name: projects.name })
- .from(projects)
- .where(eq(projects.id, input.projectId))
- .limit(1)
-
- if (project.length > 0) {
- projectName = `${project[0].code} (${project[0].name})`
- }
- }
- // 표준 공고문 템플릿 가져오기
+ // 표준 공고문 템플릿 가져오기 (noticeType별)
let standardContent = ''
if (!input.content) {
try {
const template = await tx
.select({ content: biddingNoticeTemplate.content })
.from(biddingNoticeTemplate)
- .where(eq(biddingNoticeTemplate.type, 'standard'))
+ .where(
+ and(
+ eq(biddingNoticeTemplate.isTemplate, true),
+ eq(biddingNoticeTemplate.type, input.noticeType || 'standard')
+ )
+ )
+ .orderBy(desc(biddingNoticeTemplate.updatedAt))
.limit(1)
-
+
if (template.length > 0) {
standardContent = template[0].content
}
@@ -552,22 +1093,26 @@ export async function createBidding(input: CreateBiddingInput, userId: string) {
.insert(biddings)
.values({
biddingNumber,
+ originalBiddingNumber: null, // 원입찰번호는 단순 정보이므로 null
revision: input.revision || 0,
- // 프로젝트 정보
- projectId: input.projectId,
+ // 프로젝트 정보 (PR 아이템에서 설정됨)
projectName,
itemName: input.itemName,
title: input.title,
description: input.description,
- content: input.content || standardContent,
contractType: input.contractType,
biddingType: input.biddingType,
awardCount: input.awardCount,
- contractStartDate: input.contractStartDate ? parseDate(input.contractStartDate) : null,
- contractEndDate: input.contractEndDate ? parseDate(input.contractEndDate) : null,
+ contractStartDate: input.contractStartDate ? parseDate(input.contractStartDate) : new Date(),
+ contractEndDate: input.contractEndDate ? parseDate(input.contractEndDate) : (() => {
+ const startDate = input.contractStartDate ? new Date(input.contractStartDate) : new Date()
+ const endDate = new Date(startDate)
+ endDate.setFullYear(endDate.getFullYear() + 1) // 1년 후
+ return endDate
+ })(),
// 자동 등록일 설정
biddingRegistrationDate: new Date(),
@@ -588,9 +1133,17 @@ export async function createBidding(input: CreateBiddingInput, userId: string) {
// biddingSourceType: input.biddingSourceType || 'manual',
isPublic: input.isPublic || false,
isUrgent: input.isUrgent || false,
- managerName: input.managerName,
- managerEmail: input.managerEmail,
- managerPhone: input.managerPhone,
+
+ // 구매조직
+ purchasingOrganization: input.purchasingOrganization,
+
+ // 담당자 정보 (user FK)
+ bidPicId: input.bidPicId ? parseInt(input.bidPicId.toString()) : null,
+ bidPicName: input.bidPicName || null,
+ bidPicCode: input.bidPicCode || null,
+ supplyPicId: input.supplyPicId ? parseInt(input.supplyPicId.toString()) : null,
+ supplyPicName: input.supplyPicName || null,
+ supplyPicCode: input.supplyPicCode || null,
remarks: input.remarks,
createdBy: userName,
@@ -600,7 +1153,15 @@ export async function createBidding(input: CreateBiddingInput, userId: string) {
const biddingId = newBidding.id
- // 2. 사양설명회 정보 저장 (있는 경우)
+ // 2. 입찰공고 생성 (템플릿에서 복제)
+ await tx.insert(biddingNoticeTemplate).values({
+ biddingId,
+ title: input.title + ' 입찰공고',
+ content: input.content || standardContent,
+ isTemplate: false,
+ })
+
+ // 3. 사양설명회 정보 저장 (있는 경우)
if (input.specificationMeeting) {
const [newSpecMeeting] = await tx
.insert(specificationMeetings)
@@ -666,9 +1227,10 @@ export async function createBidding(input: CreateBiddingInput, userId: string) {
paymentTerms: input.biddingConditions.paymentTerms,
taxConditions: input.biddingConditions.taxConditions,
incoterms: input.biddingConditions.incoterms,
+ incotermsOption: input.biddingConditions.incotermsOption,
contractDeliveryDate: input.biddingConditions.contractDeliveryDate || null,
- shippingPort: input.biddingConditions.shippingPort,
- destinationPort: input.biddingConditions.destinationPort,
+ shippingPort: input.biddingConditions.shippingPort || null,
+ destinationPort: input.biddingConditions.destinationPort || null,
isPriceAdjustmentApplicable: input.biddingConditions.isPriceAdjustmentApplicable,
sparePartOptions: input.biddingConditions.sparePartOptions,
})
@@ -684,18 +1246,61 @@ export async function createBidding(input: CreateBiddingInput, userId: string) {
// PR 아이템 저장
const [newPrItem] = await tx.insert(prItemsForBidding).values({
biddingId,
- itemNumber: prItem.itemCode, // itemCode를 itemNumber로 매핑
- projectInfo: '', // 필요시 추가
- itemInfo: prItem.itemInfo,
- shi: '', // 필요시 추가
+ projectId: prItem.projectId, // 프로젝트 ID 추가
+ projectInfo: prItem.projectInfo || '', // 프로젝트 정보 (기존 호환성 유지)
+ shi: prItem.shi || '', // SHI 정보
requestedDeliveryDate: prItem.requestedDeliveryDate ? new Date(prItem.requestedDeliveryDate) : null,
- annualUnitPrice: null, // 필요시 추가
- currency: 'KRW', // 기본값 또는 입력받은 값
+
+ // 자재 그룹 정보 (필수)
+ materialGroupNumber: prItem.materialGroupNumber,
+ materialGroupInfo: prItem.materialGroupInfo,
+
+ // 자재 정보
+ materialNumber: prItem.materialNumber || null,
+ materialInfo: prItem.materialInfo || null,
+
+ // 가격 정보
+ annualUnitPrice: prItem.annualUnitPrice ? parseFloat(prItem.annualUnitPrice) : null,
+ currency: prItem.currency || 'KRW',
+
+ // 수량 및 중량
quantity: prItem.quantity ? parseFloat(prItem.quantity) : null,
- quantityUnit: prItem.quantityUnit as any, // enum 타입에 맞게
+ quantityUnit: prItem.quantityUnit as any,
totalWeight: prItem.totalWeight ? parseFloat(prItem.totalWeight) : null,
- weightUnit: prItem.weightUnit as any, // enum 타입에 맞게
- materialDescription: '', // 필요시 추가
+ weightUnit: prItem.weightUnit as any,
+
+ // 단위 정보
+ priceUnit: prItem.priceUnit || null,
+ purchaseUnit: prItem.purchaseUnit || null,
+ materialWeight: prItem.materialWeight ? parseFloat(prItem.materialWeight) : null,
+
+ // WBS 정보
+ wbsCode: prItem.wbsCode || null,
+ wbsName: prItem.wbsName || null,
+
+ // Cost Center 정보
+ costCenterCode: prItem.costCenterCode || null,
+ costCenterName: prItem.costCenterName || null,
+
+ // GL Account 정보
+ glAccountCode: prItem.glAccountCode || null,
+ glAccountName: prItem.glAccountName || null,
+
+ // 내정 정보
+ targetUnitPrice: prItem.targetUnitPrice ? parseFloat(prItem.targetUnitPrice) : null,
+ targetAmount: prItem.targetAmount ? parseFloat(prItem.targetAmount) : null,
+ targetCurrency: prItem.targetCurrency || 'KRW',
+
+ // 예산 정보
+ budgetAmount: prItem.budgetAmount ? parseFloat(prItem.budgetAmount) : null,
+ budgetCurrency: prItem.budgetCurrency || 'KRW',
+
+ // 실적 정보
+ actualAmount: prItem.actualAmount ? parseFloat(prItem.actualAmount) : null,
+ actualCurrency: prItem.actualCurrency || 'KRW',
+
+ // 상세 정보
+ materialDescription: prItem.materialDescription || '',
prNumber: prItem.prNumber,
hasSpecDocument: prItem.specFiles.length > 0,
isRepresentative: prItem.isRepresentative,
@@ -724,7 +1329,7 @@ export async function createBidding(input: CreateBiddingInput, userId: string) {
mimeType: file.type,
filePath: saveResult.publicPath!,
// publicPath: saveResult.publicPath,
- title: `${prItem.itemInfo || prItem.itemCode} 스펙 - ${file.name}`,
+ title: `${prItem.materialGroupInfo || prItem.materialGroupNumber} 스펙 - ${file.name}`,
description: `PR ${prItem.prNumber}의 스펙 문서`,
isPublic: false,
isRequired: false,
@@ -840,9 +1445,15 @@ export async function updateBidding(input: UpdateBiddingInput, userId: string) {
if (input.status !== undefined) updateData.status = input.status
if (input.isPublic !== undefined) updateData.isPublic = input.isPublic
if (input.isUrgent !== undefined) updateData.isUrgent = input.isUrgent
- if (input.managerName !== undefined) updateData.managerName = input.managerName
- if (input.managerEmail !== undefined) updateData.managerEmail = input.managerEmail
- if (input.managerPhone !== undefined) updateData.managerPhone = input.managerPhone
+
+ // 구매조직
+ if (input.purchasingOrganization !== undefined) updateData.purchasingOrganization = input.purchasingOrganization
+
+ // 담당자 정보 (user FK)
+ if (input.bidPicId !== undefined) updateData.bidPicId = input.bidPicId
+ if (input.bidPicName !== undefined) updateData.bidPicName = input.bidPicName
+ if (input.supplyPicId !== undefined) updateData.supplyPicId = input.supplyPicId
+ if (input.supplyPicName !== undefined) updateData.supplyPicName = input.supplyPicName
if (input.remarks !== undefined) updateData.remarks = input.remarks
@@ -908,6 +1519,12 @@ export async function deleteBidding(id: number) {
// 단일 입찰 조회
export async function getBiddingById(id: number) {
try {
+ // ID 유효성 검증
+ if (!id || isNaN(id) || id <= 0) {
+ console.warn('Invalid bidding ID provided to getBiddingById:', id)
+ return null
+ }
+
const bidding = await db
.select()
.from(biddings)
@@ -979,17 +1596,52 @@ export interface PRDetails {
}>
items: Array<{
id: number
- itemNumber?: string | null
- itemInfo: string
- quantity?: number | null
- quantityUnit?: string | null
- requestedDeliveryDate?: string | null
- prNumber?: string | null
- annualUnitPrice?: number | null
- currency: string
- totalWeight?: number | null
- weightUnit?: string | null
- materialDescription?: string | null
+ itemNumber: string | null
+ prNumber: string | null
+ projectInfo: string | null
+ shi: string | null
+ // 자재 그룹 정보
+ materialGroupNumber: string | null
+ materialGroupInfo: string | null
+ // 자재 정보
+ materialNumber: string | null
+ materialInfo: string | null
+ // 품목 정보
+ itemInfo: string | null
+ // 수량 및 중량
+ quantity: number | null
+ quantityUnit: string | null
+ totalWeight: number | null
+ weightUnit: string | null
+ // 가격 정보
+ annualUnitPrice: number | null
+ currency: string | null
+ // 단위 정보
+ priceUnit: string | null
+ purchaseUnit: string | null
+ materialWeight: number | null
+ // WBS 정보
+ wbsCode: string | null
+ wbsName: string | null
+ // Cost Center 정보
+ costCenterCode: string | null
+ costCenterName: string | null
+ // GL Account 정보
+ glAccountCode: string | null
+ glAccountName: string | null
+ // 내정 정보
+ targetUnitPrice: number | null
+ targetAmount: number | null
+ targetCurrency: string | null
+ // 예산 정보
+ budgetAmount: number | null
+ budgetCurrency: string | null
+ // 실적 정보
+ actualAmount: number | null
+ actualCurrency: string | null
+ // 납품 일정
+ requestedDeliveryDate: string | null
+ // SPEC 문서
hasSpecDocument: boolean
createdAt: string
updatedAt: string
@@ -1000,7 +1652,7 @@ export interface PRDetails {
fileSize: number
filePath: string
uploadedAt: string
- title?: string | null
+ title: string | null
}>
}>
}
@@ -1162,21 +1814,56 @@ export async function getPRDetailsAction(
)
)
- // 5. 데이터 직렬화
+ // 5. 데이터 직렬화 (모든 필드 포함)
return {
id: item.id,
itemNumber: item.itemNumber,
+ prNumber: item.prNumber,
+ projectInfo: item.projectInfo,
+ shi: item.shi,
+ // 자재 그룹 정보
+ materialGroupNumber: item.materialGroupNumber,
+ materialGroupInfo: item.materialGroupInfo,
+ // 자재 정보
+ materialNumber: item.materialNumber,
+ materialInfo: item.materialInfo,
+ // 품목 정보
itemInfo: item.itemInfo,
+ // 수량 및 중량
quantity: item.quantity ? Number(item.quantity) : null,
quantityUnit: item.quantityUnit,
- requestedDeliveryDate: item.requestedDeliveryDate || null,
- prNumber: item.prNumber,
- annualUnitPrice: item.annualUnitPrice ? Number(item.annualUnitPrice) : null,
- currency: item.currency,
totalWeight: item.totalWeight ? Number(item.totalWeight) : null,
weightUnit: item.weightUnit,
- materialDescription: item.materialDescription,
- hasSpecDocument: item.hasSpecDocument,
+ // 가격 정보
+ annualUnitPrice: item.annualUnitPrice ? Number(item.annualUnitPrice) : null,
+ currency: item.currency,
+ // 단위 정보
+ priceUnit: item.priceUnit,
+ purchaseUnit: item.purchaseUnit,
+ materialWeight: item.materialWeight ? Number(item.materialWeight) : null,
+ // WBS 정보
+ wbsCode: item.wbsCode,
+ wbsName: item.wbsName,
+ // Cost Center 정보
+ costCenterCode: item.costCenterCode,
+ costCenterName: item.costCenterName,
+ // GL Account 정보
+ glAccountCode: item.glAccountCode,
+ glAccountName: item.glAccountName,
+ // 내정 정보
+ targetUnitPrice: item.targetUnitPrice ? Number(item.targetUnitPrice) : null,
+ targetAmount: item.targetAmount ? Number(item.targetAmount) : null,
+ targetCurrency: item.targetCurrency,
+ // 예산 정보
+ budgetAmount: item.budgetAmount ? Number(item.budgetAmount) : null,
+ budgetCurrency: item.budgetCurrency,
+ // 실적 정보
+ actualAmount: item.actualAmount ? Number(item.actualAmount) : null,
+ actualCurrency: item.actualCurrency,
+ // 납품 일정
+ requestedDeliveryDate: item.requestedDeliveryDate || null,
+ // 기타
+ hasSpecDocument: item.hasSpecDocument || false,
createdAt: item.createdAt?.toISOString() || '',
updatedAt: item.updatedAt?.toISOString() || '',
specDocuments: specDocuments.map(doc => ({
@@ -1302,12 +1989,688 @@ export async function getBiddingConditions(biddingId: number) {
}
// 입찰 조건 업데이트
+// === 입찰 관리 서버 액션들 ===
+
+// 입찰 기본 정보 업데이트 (관리 페이지용)
+export async function updateBiddingBasicInfo(
+ biddingId: number,
+ updates: {
+ title?: string
+ description?: string
+ content?: string
+ noticeType?: string
+ contractType?: string
+ biddingType?: string
+ biddingTypeCustom?: string
+ awardCount?: string
+ budget?: string
+ finalBidPrice?: string
+ targetPrice?: string
+ prNumber?: string
+ contractStartDate?: string
+ contractEndDate?: string
+ submissionStartDate?: string
+ submissionEndDate?: string
+ evaluationDate?: string
+ hasSpecificationMeeting?: boolean
+ hasPrDocument?: boolean
+ currency?: string
+ purchasingOrganization?: string
+ bidPicName?: string
+ bidPicCode?: string
+ supplyPicName?: string
+ supplyPicCode?: string
+ requesterName?: string
+ remarks?: string
+ },
+ userId: string
+) {
+ try {
+ const userName = await getUserNameById(userId)
+
+ // 존재 여부 확인
+ const existing = await db
+ .select({ id: biddings.id })
+ .from(biddings)
+ .where(eq(biddings.id, biddingId))
+ .limit(1)
+
+ if (existing.length === 0) {
+ return {
+ success: false,
+ error: '존재하지 않는 입찰입니다.'
+ }
+ }
+
+ // 날짜 문자열을 Date 객체로 변환
+ const parseDate = (dateStr?: string) => {
+ if (!dateStr) return undefined
+ try {
+ return new Date(dateStr)
+ } catch {
+ return undefined
+ }
+ }
+
+ // 숫자 문자열을 숫자로 변환 (빈 문자열은 null)
+ const parseNumeric = (value?: string): number | null | undefined => {
+ if (value === undefined) return undefined
+ if (value === '' || value === null) return null
+ const parsed = parseFloat(value)
+ return isNaN(parsed) ? null : parsed
+ }
+
+ // 업데이트할 데이터 준비
+ const updateData: any = {
+ updatedAt: new Date(),
+ updatedBy: userName,
+ }
+
+ // 정의된 필드들만 업데이트
+ if (updates.title !== undefined) updateData.title = updates.title
+ if (updates.description !== undefined) updateData.description = updates.description
+ if (updates.content !== undefined) updateData.content = updates.content
+ if (updates.noticeType !== undefined) updateData.noticeType = updates.noticeType
+ if (updates.contractType !== undefined) updateData.contractType = updates.contractType
+ if (updates.biddingType !== undefined) updateData.biddingType = updates.biddingType
+ if (updates.biddingTypeCustom !== undefined) updateData.biddingTypeCustom = updates.biddingTypeCustom
+ if (updates.awardCount !== undefined) updateData.awardCount = updates.awardCount
+ if (updates.budget !== undefined) updateData.budget = parseNumeric(updates.budget)
+ if (updates.finalBidPrice !== undefined) updateData.finalBidPrice = parseNumeric(updates.finalBidPrice)
+ if (updates.targetPrice !== undefined) updateData.targetPrice = parseNumeric(updates.targetPrice)
+ if (updates.prNumber !== undefined) updateData.prNumber = updates.prNumber
+ if (updates.contractStartDate !== undefined) updateData.contractStartDate = parseDate(updates.contractStartDate)
+ if (updates.contractEndDate !== undefined) updateData.contractEndDate = parseDate(updates.contractEndDate)
+ if (updates.submissionStartDate !== undefined) updateData.submissionStartDate = parseDate(updates.submissionStartDate)
+ if (updates.submissionEndDate !== undefined) updateData.submissionEndDate = parseDate(updates.submissionEndDate)
+ if (updates.evaluationDate !== undefined) updateData.evaluationDate = parseDate(updates.evaluationDate)
+ if (updates.hasSpecificationMeeting !== undefined) updateData.hasSpecificationMeeting = updates.hasSpecificationMeeting
+ if (updates.hasPrDocument !== undefined) updateData.hasPrDocument = updates.hasPrDocument
+ if (updates.currency !== undefined) updateData.currency = updates.currency
+ if (updates.purchasingOrganization !== undefined) updateData.purchasingOrganization = updates.purchasingOrganization
+ if (updates.bidPicName !== undefined) updateData.bidPicName = updates.bidPicName
+ if (updates.bidPicCode !== undefined) updateData.bidPicCode = updates.bidPicCode
+ if (updates.supplyPicName !== undefined) updateData.supplyPicName = updates.supplyPicName
+ if (updates.supplyPicCode !== undefined) updateData.supplyPicCode = updates.supplyPicCode
+ if (updates.requesterName !== undefined) updateData.requesterName = updates.requesterName
+ if (updates.remarks !== undefined) updateData.remarks = updates.remarks
+
+ // 데이터베이스 업데이트
+ await db
+ .update(biddings)
+ .set(updateData)
+ .where(eq(biddings.id, biddingId))
+
+ revalidatePath(`/evcp/bid/${biddingId}`)
+ revalidatePath(`/evcp/bid/${biddingId}/info`)
+
+ return {
+ success: true,
+ message: '입찰 기본 정보가 성공적으로 업데이트되었습니다.'
+ }
+ } catch (error) {
+ console.error('Failed to update bidding basic info:', error)
+ return {
+ success: false,
+ error: '입찰 기본 정보 업데이트에 실패했습니다.'
+ }
+ }
+}
+
+// 입찰 일정 업데이트
+export async function updateBiddingSchedule(
+ biddingId: number,
+ schedule: {
+ submissionStartDate?: string
+ submissionEndDate?: string
+ remarks?: string
+ isUrgent?: boolean
+ hasSpecificationMeeting?: boolean
+ },
+ userId: string,
+ specificationMeeting?: {
+ meetingDate: string
+ meetingTime: string
+ location: string
+ address: string
+ contactPerson: string
+ contactPhone: string
+ contactEmail: string
+ agenda: string
+ materials: string
+ notes: string
+ isRequired: boolean
+ }
+) {
+ try {
+ const userName = await getUserNameById(userId)
+
+ // 날짜 문자열을 Date 객체로 변환
+ const parseDate = (dateStr?: string) => {
+ if (!dateStr) return undefined
+ try {
+ return new Date(dateStr)
+ } catch {
+ return undefined
+ }
+ }
+
+ return await db.transaction(async (tx) => {
+ const updateData: any = {
+ updatedAt: new Date(),
+ updatedBy: userName,
+ }
+
+ if (schedule.submissionStartDate !== undefined) updateData.submissionStartDate = parseDate(schedule.submissionStartDate)
+ if (schedule.submissionEndDate !== undefined) updateData.submissionEndDate = parseDate(schedule.submissionEndDate)
+ if (schedule.remarks !== undefined) updateData.remarks = schedule.remarks
+ if (schedule.isUrgent !== undefined) updateData.isUrgent = schedule.isUrgent
+ if (schedule.hasSpecificationMeeting !== undefined) updateData.hasSpecificationMeeting = schedule.hasSpecificationMeeting
+
+ await tx
+ .update(biddings)
+ .set(updateData)
+ .where(eq(biddings.id, biddingId))
+
+ // 사양설명회 정보 저장/업데이트
+ if (schedule.hasSpecificationMeeting && specificationMeeting) {
+ // 기존 사양설명회 정보 확인
+ const existingMeeting = await tx
+ .select()
+ .from(specificationMeetings)
+ .where(eq(specificationMeetings.biddingId, biddingId))
+ .limit(1)
+
+ if (existingMeeting.length > 0) {
+ // 기존 정보 업데이트
+ await tx
+ .update(specificationMeetings)
+ .set({
+ meetingDate: new Date(specificationMeeting.meetingDate),
+ meetingTime: specificationMeeting.meetingTime || null,
+ location: specificationMeeting.location,
+ address: specificationMeeting.address || null,
+ contactPerson: specificationMeeting.contactPerson,
+ contactPhone: specificationMeeting.contactPhone || null,
+ contactEmail: specificationMeeting.contactEmail || null,
+ agenda: specificationMeeting.agenda || null,
+ materials: specificationMeeting.materials || null,
+ notes: specificationMeeting.notes || null,
+ isRequired: specificationMeeting.isRequired || false,
+ updatedAt: new Date(),
+ })
+ .where(eq(specificationMeetings.id, existingMeeting[0].id))
+ } else {
+ // 새로 생성
+ await tx
+ .insert(specificationMeetings)
+ .values({
+ biddingId,
+ meetingDate: new Date(specificationMeeting.meetingDate),
+ meetingTime: specificationMeeting.meetingTime || null,
+ location: specificationMeeting.location,
+ address: specificationMeeting.address || null,
+ contactPerson: specificationMeeting.contactPerson,
+ contactPhone: specificationMeeting.contactPhone || null,
+ contactEmail: specificationMeeting.contactEmail || null,
+ agenda: specificationMeeting.agenda || null,
+ materials: specificationMeeting.materials || null,
+ notes: specificationMeeting.notes || null,
+ isRequired: specificationMeeting.isRequired || false,
+ })
+ }
+ } else if (!schedule.hasSpecificationMeeting) {
+ // 사양설명회 실시 여부가 false로 변경된 경우, 관련 정보 삭제
+ await tx
+ .delete(specificationMeetings)
+ .where(eq(specificationMeetings.biddingId, biddingId))
+ }
+
+ revalidatePath(`/evcp/bid/${biddingId}`)
+ revalidatePath(`/evcp/bid/${biddingId}/schedule`)
+
+ return {
+ success: true,
+ message: '입찰 일정이 성공적으로 업데이트되었습니다.'
+ }
+ })
+ } catch (error) {
+ console.error('Failed to update bidding schedule:', error)
+ return {
+ success: false,
+ error: '입찰 일정 업데이트에 실패했습니다.'
+ }
+ }
+}
+
+// 입찰 품목 관리 액션들
+export async function getBiddingItems(biddingId: number) {
+ try {
+ // PR 아이템 조회 (실제로는 prItemsForBidding 테이블에서 조회)
+ const items = await db
+ .select({
+ id: prItemsForBidding.id,
+ itemName: prItemsForBidding.itemName,
+ description: prItemsForBidding.description,
+ quantity: prItemsForBidding.quantity,
+ unit: prItemsForBidding.unit,
+ unitPrice: prItemsForBidding.unitPrice,
+ totalPrice: prItemsForBidding.totalPrice,
+ currency: prItemsForBidding.currency,
+ })
+ .from(prItemsForBidding)
+ .where(eq(prItemsForBidding.biddingId, biddingId))
+ .orderBy(prItemsForBidding.id)
+
+ return {
+ success: true,
+ data: items
+ }
+ } catch (error) {
+ console.error('Failed to get bidding items:', error)
+ return {
+ success: false,
+ error: '품목 정보를 불러오는데 실패했습니다.'
+ }
+ }
+}
+
+export async function updateBiddingItem(
+ itemId: number,
+ updates: {
+ itemName?: string
+ description?: string
+ quantity?: number
+ unit?: string
+ unitPrice?: number
+ currency?: string
+ }
+) {
+ try {
+ const updateData: any = {
+ updatedAt: new Date(),
+ }
+
+ if (updates.itemName !== undefined) updateData.itemName = updates.itemName
+ if (updates.description !== undefined) updateData.description = updates.description
+ if (updates.quantity !== undefined) updateData.quantity = updates.quantity
+ if (updates.unit !== undefined) updateData.unit = updates.unit
+ if (updates.unitPrice !== undefined) updateData.unitPrice = updates.unitPrice
+ if (updates.currency !== undefined) updateData.currency = updates.currency
+
+ // 총액 자동 계산
+ if (updates.quantity !== undefined || updates.unitPrice !== undefined) {
+ const item = await db
+ .select({ quantity: prItemsForBidding.quantity, unitPrice: prItemsForBidding.unitPrice })
+ .from(prItemsForBidding)
+ .where(eq(prItemsForBidding.id, itemId))
+ .limit(1)
+
+ if (item.length > 0) {
+ const quantity = updates.quantity ?? item[0].quantity ?? 0
+ const unitPrice = updates.unitPrice ?? item[0].unitPrice ?? 0
+ updateData.totalPrice = quantity * unitPrice
+ }
+ }
+
+ await db
+ .update(prItemsForBidding)
+ .set(updateData)
+ .where(eq(prItemsForBidding.id, itemId))
+
+ return {
+ success: true,
+ message: '품목 정보가 성공적으로 업데이트되었습니다.'
+ }
+ } catch (error) {
+ console.error('Failed to update bidding item:', error)
+ return {
+ success: false,
+ error: '품목 정보 업데이트에 실패했습니다.'
+ }
+ }
+}
+
+export async function addBiddingItem(
+ biddingId: number,
+ item: {
+ itemName: string
+ description?: string
+ quantity?: number
+ unit?: string
+ unitPrice?: number
+ currency?: string
+ }
+) {
+ try {
+ const totalPrice = (item.quantity || 0) * (item.unitPrice || 0)
+
+ await db.insert(prItemsForBidding).values({
+ biddingId,
+ itemName: item.itemName,
+ description: item.description,
+ quantity: item.quantity || 0,
+ unit: item.unit,
+ unitPrice: item.unitPrice || 0,
+ totalPrice,
+ currency: item.currency || 'KRW',
+ })
+
+ return {
+ success: true,
+ message: '품목이 성공적으로 추가되었습니다.'
+ }
+ } catch (error) {
+ console.error('Failed to add bidding item:', error)
+ return {
+ success: false,
+ error: '품목 추가에 실패했습니다.'
+ }
+ }
+}
+
+export async function removeBiddingItem(itemId: number) {
+ try {
+ await db
+ .delete(prItemsForBidding)
+ .where(eq(prItemsForBidding.id, itemId))
+
+ return {
+ success: true,
+ message: '품목이 성공적으로 삭제되었습니다.'
+ }
+ } catch (error) {
+ console.error('Failed to remove bidding item:', error)
+ return {
+ success: false,
+ error: '품목 삭제에 실패했습니다.'
+ }
+ }
+}
+
+// PR 아이템 추가 (전체 필드 지원)
+export async function addPRItemForBidding(
+ biddingId: number,
+ item: {
+ projectId?: number | null
+ projectInfo?: string | null
+ shi?: string | null
+ materialGroupNumber?: string | null
+ materialGroupInfo?: string | null
+ materialNumber?: string | null
+ materialInfo?: string | null
+ quantity?: string | null
+ quantityUnit?: string | null
+ totalWeight?: string | null
+ weightUnit?: string | null
+ priceUnit?: string | null
+ purchaseUnit?: string | null
+ materialWeight?: string | null
+ wbsCode?: string | null
+ wbsName?: string | null
+ costCenterCode?: string | null
+ costCenterName?: string | null
+ glAccountCode?: string | null
+ glAccountName?: string | null
+ targetUnitPrice?: string | null
+ targetAmount?: string | null
+ targetCurrency?: string | null
+ budgetAmount?: string | null
+ budgetCurrency?: string | null
+ actualAmount?: string | null
+ actualCurrency?: string | null
+ requestedDeliveryDate?: string | null
+ prNumber?: string | null
+ currency?: string | null
+ annualUnitPrice?: string | null
+ hasSpecDocument?: boolean
+ }
+) {
+ try {
+ const result = await db.insert(prItemsForBidding).values({
+ biddingId,
+ projectId: item.projectId || null,
+ projectInfo: item.projectInfo || null,
+ shi: item.shi || null,
+ materialGroupNumber: item.materialGroupNumber || null,
+ materialGroupInfo: item.materialGroupInfo || null,
+ materialNumber: item.materialNumber || null,
+ materialInfo: item.materialInfo || null,
+ quantity: item.quantity ? parseFloat(item.quantity) : null,
+ quantityUnit: item.quantityUnit || null,
+ totalWeight: item.totalWeight ? parseFloat(item.totalWeight) : null,
+ weightUnit: item.weightUnit || null,
+ priceUnit: item.priceUnit || null,
+ purchaseUnit: item.purchaseUnit || null,
+ materialWeight: item.materialWeight ? parseFloat(item.materialWeight) : null,
+ wbsCode: item.wbsCode || null,
+ wbsName: item.wbsName || null,
+ costCenterCode: item.costCenterCode || null,
+ costCenterName: item.costCenterName || null,
+ glAccountCode: item.glAccountCode || null,
+ glAccountName: item.glAccountName || null,
+ targetUnitPrice: item.targetUnitPrice ? parseFloat(item.targetUnitPrice) : null,
+ targetAmount: item.targetAmount ? parseFloat(item.targetAmount) : null,
+ targetCurrency: item.targetCurrency || 'KRW',
+ budgetAmount: item.budgetAmount ? parseFloat(item.budgetAmount) : null,
+ budgetCurrency: item.budgetCurrency || 'KRW',
+ actualAmount: item.actualAmount ? parseFloat(item.actualAmount) : null,
+ actualCurrency: item.actualCurrency || 'KRW',
+ requestedDeliveryDate: item.requestedDeliveryDate ? new Date(item.requestedDeliveryDate) : null,
+ prNumber: item.prNumber || null,
+ currency: item.currency || 'KRW',
+ annualUnitPrice: item.annualUnitPrice ? parseFloat(item.annualUnitPrice) : null,
+ hasSpecDocument: item.hasSpecDocument || false,
+ }).returning()
+
+ revalidatePath(`/evcp/bid/${biddingId}/info`)
+ revalidatePath(`/evcp/bid/${biddingId}`)
+
+ return {
+ success: true,
+ data: result[0],
+ message: '품목이 성공적으로 추가되었습니다.'
+ }
+ } catch (error) {
+ console.error('Failed to add PR item:', error)
+ return {
+ success: false,
+ error: '품목 추가에 실패했습니다.'
+ }
+ }
+}
+
+// 입찰 업체 관리 액션들
+export async function getBiddingVendors(biddingId: number) {
+ try {
+ const vendorsData = await db
+ .select({
+ id: biddingCompanies.id,
+ companyId: biddingCompanies.companyId, // 벤더 ID 추가
+ vendorName: vendors.vendorName,
+ vendorCode: vendors.vendorCode,
+ contactPerson: biddingCompanies.contactPerson,
+ contactEmail: biddingCompanies.contactEmail,
+ contactPhone: biddingCompanies.contactPhone,
+ quotationAmount: biddingCompanies.finalQuoteAmount,
+ currency: sql<string>`'KRW'`,
+ invitationStatus: biddingCompanies.invitationStatus,
+ isPriceAdjustmentApplicableQuestion: biddingCompanies.isPriceAdjustmentApplicableQuestion,
+ })
+ .from(biddingCompanies)
+ .leftJoin(vendors, eq(biddingCompanies.companyId, vendors.id))
+ .where(eq(biddingCompanies.biddingId, biddingId))
+ .orderBy(biddingCompanies.id)
+
+ return {
+ success: true,
+ data: vendorsData
+ }
+ } catch (error) {
+ console.error('Failed to get bidding vendors:', error)
+ return {
+ success: false,
+ error: '업체 정보를 불러오는데 실패했습니다.'
+ }
+ }
+}
+
+export async function updateBiddingCompanyPriceAdjustmentQuestion(
+ biddingCompanyId: number,
+ isPriceAdjustmentApplicableQuestion: boolean
+) {
+ try {
+ await db
+ .update(biddingCompanies)
+ .set({
+ isPriceAdjustmentApplicableQuestion,
+ updatedAt: new Date(),
+ })
+ .where(eq(biddingCompanies.id, biddingCompanyId))
+
+ return {
+ success: true,
+ message: '연동제 적용요건 문의 여부가 성공적으로 업데이트되었습니다.'
+ }
+ } catch (error) {
+ console.error('Failed to update price adjustment question:', error)
+ return {
+ success: false,
+ error: '연동제 적용요건 문의 여부 업데이트에 실패했습니다.'
+ }
+ }
+}
+
+export async function updateVendorContact(
+ biddingCompanyId: number,
+ contact: {
+ contactPerson?: string
+ contactEmail?: string
+ contactPhone?: string
+ }
+) {
+ try {
+ // biddingCompanies 테이블에 연락처 정보가 직접 저장되어 있으므로 직접 업데이트
+ await db
+ .update(biddingCompanies)
+ .set({
+ contactPerson: contact.contactPerson,
+ contactEmail: contact.contactEmail,
+ contactPhone: contact.contactPhone,
+ updatedAt: new Date(),
+ })
+ .where(eq(biddingCompanies.id, biddingCompanyId))
+
+ return {
+ success: true,
+ message: '담당자 정보가 성공적으로 업데이트되었습니다.'
+ }
+ } catch (error) {
+ console.error('Failed to update vendor contact:', error)
+ return {
+ success: false,
+ error: '담당자 정보 업데이트에 실패했습니다.'
+ }
+ }
+}
+
+// 입찰 참여 업체 담당자 관리 함수들
+export async function getBiddingCompanyContacts(biddingId: number, vendorId: number) {
+ try {
+ const contacts = await db
+ .select({
+ id: biddingCompaniesContacts.id,
+ biddingId: biddingCompaniesContacts.biddingId,
+ vendorId: biddingCompaniesContacts.vendorId,
+ contactName: biddingCompaniesContacts.contactName,
+ contactEmail: biddingCompaniesContacts.contactEmail,
+ contactNumber: biddingCompaniesContacts.contactNumber,
+ createdAt: biddingCompaniesContacts.createdAt,
+ updatedAt: biddingCompaniesContacts.updatedAt,
+ })
+ .from(biddingCompaniesContacts)
+ .where(
+ and(
+ eq(biddingCompaniesContacts.biddingId, biddingId),
+ eq(biddingCompaniesContacts.vendorId, vendorId)
+ )
+ )
+ .orderBy(asc(biddingCompaniesContacts.contactName))
+
+ return {
+ success: true,
+ data: contacts
+ }
+ } catch (error) {
+ console.error('Failed to get bidding company contacts:', error)
+ return {
+ success: false,
+ error: '담당자 목록을 불러오는데 실패했습니다.'
+ }
+ }
+}
+
+export async function createBiddingCompanyContact(
+ biddingId: number,
+ vendorId: number,
+ contact: {
+ contactName: string
+ contactEmail: string
+ contactNumber?: string
+ }
+) {
+ try {
+ const [newContact] = await db
+ .insert(biddingCompaniesContacts)
+ .values({
+ biddingId,
+ vendorId,
+ contactName: contact.contactName,
+ contactEmail: contact.contactEmail,
+ contactNumber: contact.contactNumber || null,
+ })
+ .returning()
+
+ return {
+ success: true,
+ data: newContact,
+ message: '담당자가 성공적으로 추가되었습니다.'
+ }
+ } catch (error) {
+ console.error('Failed to create bidding company contact:', error)
+ return {
+ success: false,
+ error: '담당자 추가에 실패했습니다.'
+ }
+ }
+}
+
+export async function deleteBiddingCompanyContact(contactId: number) {
+ try {
+ await db
+ .delete(biddingCompaniesContacts)
+ .where(eq(biddingCompaniesContacts.id, contactId))
+
+ return {
+ success: true,
+ message: '담당자가 성공적으로 삭제되었습니다.'
+ }
+ } catch (error) {
+ console.error('Failed to delete bidding company contact:', error)
+ return {
+ success: false,
+ error: '담당자 삭제에 실패했습니다.'
+ }
+ }
+}
+
export async function updateBiddingConditions(
biddingId: number,
updates: {
paymentTerms?: string
taxConditions?: string
incoterms?: string
+ incotermsOption?: string
contractDeliveryDate?: string
shippingPort?: string
destinationPort?: string
@@ -1328,6 +2691,7 @@ export async function updateBiddingConditions(
paymentTerms: updates.paymentTerms,
taxConditions: updates.taxConditions,
incoterms: updates.incoterms,
+ incotermsOption: updates.incotermsOption,
contractDeliveryDate: updates.contractDeliveryDate || null,
shippingPort: updates.shippingPort,
destinationPort: updates.destinationPort,
@@ -1367,6 +2731,272 @@ export async function updateBiddingConditions(
}
}
+// 사전견적용 일반견적 생성 액션
+export async function createPreQuoteRfqAction(input: {
+ biddingId: number
+ rfqType: string
+ rfqTitle: string
+ dueDate: Date
+ picUserId: number
+ projectId?: number
+ remark?: string
+ items: Array<{
+ itemCode: string
+ itemName: string
+ materialCode?: string
+ materialName?: string
+ quantity: number
+ uom: string
+ remark?: string
+ }>
+ biddingConditions?: {
+ paymentTerms?: string | null
+ taxConditions?: string | null
+ incoterms?: string | null
+ incotermsOption?: string | null
+ contractDeliveryDate?: string | null
+ shippingPort?: string | null
+ destinationPort?: string | null
+ isPriceAdjustmentApplicable?: boolean | null
+ sparePartOptions?: string | null
+ }
+ createdBy: number
+ updatedBy: number
+}) {
+ try {
+ // 일반견적 생성 서버 액션 및 필요한 스키마 import
+ const { createGeneralRfqAction } = await import('@/lib/rfq-last/service')
+ const { rfqLastDetails, rfqLastVendorResponses, rfqLastVendorResponseHistory } = await import('@/db/schema')
+
+ // 일반견적 생성
+ const result = await createGeneralRfqAction({
+ rfqType: input.rfqType,
+ rfqTitle: input.rfqTitle,
+ dueDate: input.dueDate,
+ picUserId: input.picUserId,
+ projectId: input.projectId,
+ remark: input.remark || '',
+ items: input.items.map(item => ({
+ itemCode: item.itemCode,
+ itemName: item.itemName,
+ quantity: item.quantity,
+ uom: item.uom,
+ remark: item.remark,
+ materialCode: item.materialCode,
+ materialName: item.materialName,
+ })),
+ createdBy: input.createdBy,
+ updatedBy: input.updatedBy,
+ })
+
+ if (!result.success || !result.data) {
+ return {
+ success: false,
+ error: result.error || '사전견적용 일반견적 생성에 실패했습니다',
+ }
+ }
+
+ const rfqId = result.data.id
+ const conditions = input.biddingConditions
+
+ // 입찰 조건을 RFQ 조건으로 매핑
+ const mapBiddingConditionsToRfqConditions = () => {
+ if (!conditions) {
+ return {
+ currency: 'KRW',
+ paymentTermsCode: undefined,
+ incotermsCode: undefined,
+ incotermsDetail: undefined,
+ deliveryDate: undefined,
+ taxCode: undefined,
+ placeOfShipping: undefined,
+ placeOfDestination: undefined,
+ materialPriceRelatedYn: false,
+ sparepartYn: false,
+ sparepartDescription: undefined,
+ }
+ }
+
+ // contractDeliveryDate 문자열을 Date로 변환 (timestamp 타입용)
+ let deliveryDate: Date | undefined = undefined
+ if (conditions.contractDeliveryDate) {
+ try {
+ const date = new Date(conditions.contractDeliveryDate)
+ if (!isNaN(date.getTime())) {
+ deliveryDate = date
+ }
+ } catch (error) {
+ console.warn('Failed to parse contractDeliveryDate:', error)
+ }
+ }
+
+ return {
+ currency: 'KRW', // 기본값
+ paymentTermsCode: conditions.paymentTerms || undefined,
+ incotermsCode: conditions.incoterms || undefined,
+ incotermsDetail: conditions.incotermsOption || undefined,
+ deliveryDate: deliveryDate, // timestamp 타입 (rfqLastDetails용)
+ vendorDeliveryDate: deliveryDate, // date 타입 (rfqLastVendorResponses용)
+ taxCode: conditions.taxConditions || undefined,
+ placeOfShipping: conditions.shippingPort || undefined,
+ placeOfDestination: conditions.destinationPort || undefined,
+ materialPriceRelatedYn: conditions.isPriceAdjustmentApplicable ?? false,
+ sparepartYn: !!conditions.sparePartOptions, // sparePartOptions가 있으면 true
+ sparepartDescription: conditions.sparePartOptions || undefined,
+ }
+ }
+
+ const rfqConditions = mapBiddingConditionsToRfqConditions()
+
+ // 입찰에 참여한 업체 목록 조회
+ const vendorsResult = await getBiddingVendors(input.biddingId)
+ if (!vendorsResult.success || !vendorsResult.data || vendorsResult.data.length === 0) {
+ return {
+ success: true,
+ message: '사전견적용 일반견적이 생성되었습니다. (참여 업체 없음)',
+ data: {
+ rfqCode: result.data.rfqCode,
+ rfqId: result.data.id,
+ },
+ }
+ }
+
+ // 각 업체에 대해 rfqLastDetails와 rfqLastVendorResponses 생성
+ await db.transaction(async (tx) => {
+ for (const vendor of vendorsResult.data) {
+ if (!vendor.companyId) continue
+
+ // 1. rfqLastDetails 생성 (구매자 제시 조건)
+ const [rfqDetail] = await tx
+ .insert(rfqLastDetails)
+ .values({
+ rfqsLastId: rfqId,
+ vendorsId: vendor.companyId,
+ currency: rfqConditions.currency,
+ paymentTermsCode: rfqConditions.paymentTermsCode || null,
+ incotermsCode: rfqConditions.incotermsCode || null,
+ incotermsDetail: rfqConditions.incotermsDetail || null,
+ deliveryDate: rfqConditions.deliveryDate || null,
+ taxCode: rfqConditions.taxCode || null,
+ placeOfShipping: rfqConditions.placeOfShipping || null,
+ placeOfDestination: rfqConditions.placeOfDestination || null,
+ materialPriceRelatedYn: rfqConditions.materialPriceRelatedYn,
+ sparepartYn: rfqConditions.sparepartYn,
+ sparepartDescription: rfqConditions.sparepartDescription || null,
+ updatedBy: input.updatedBy,
+ createdBy: input.createdBy,
+ isLatest: true,
+ })
+ .returning()
+
+ // 2. rfqLastVendorResponses 생성 (초기 응답 레코드)
+ const [vendorResponse] = await tx
+ .insert(rfqLastVendorResponses)
+ .values({
+ rfqsLastId: rfqId,
+ rfqLastDetailsId: rfqDetail.id,
+ vendorId: vendor.companyId,
+ status: '대기중',
+ responseVersion: 1,
+ isLatest: true,
+ participationStatus: '미응답',
+ currency: rfqConditions.currency,
+ // 구매자 제시 조건을 벤더 제안 조건의 초기값으로 복사
+ vendorCurrency: rfqConditions.currency,
+ vendorPaymentTermsCode: rfqConditions.paymentTermsCode || null,
+ vendorIncotermsCode: rfqConditions.incotermsCode || null,
+ vendorIncotermsDetail: rfqConditions.incotermsDetail || null,
+ vendorDeliveryDate: rfqConditions.vendorDeliveryDate || null,
+ vendorTaxCode: rfqConditions.taxCode || null,
+ vendorPlaceOfShipping: rfqConditions.placeOfShipping || null,
+ vendorPlaceOfDestination: rfqConditions.placeOfDestination || null,
+ vendorMaterialPriceRelatedYn: rfqConditions.materialPriceRelatedYn,
+ vendorSparepartYn: rfqConditions.sparepartYn,
+ vendorSparepartDescription: rfqConditions.sparepartDescription || null,
+ createdBy: input.createdBy,
+ updatedBy: input.updatedBy,
+ })
+ .returning()
+
+ // 3. 이력 기록
+ await tx
+ .insert(rfqLastVendorResponseHistory)
+ .values({
+ vendorResponseId: vendorResponse.id,
+ action: '생성',
+ newStatus: '대기중',
+ changeDetails: {
+ action: '사전견적용 일반견적 생성',
+ biddingId: input.biddingId,
+ conditions: rfqConditions,
+ },
+ performedBy: input.createdBy,
+ })
+ }
+ })
+
+ return {
+ success: true,
+ message: `사전견적용 일반견적이 성공적으로 생성되었습니다. (${vendorsResult.data.length}개 업체 추가)`,
+ data: {
+ rfqCode: result.data.rfqCode,
+ rfqId: result.data.id,
+ },
+ }
+ } catch (error) {
+ console.error('Failed to create pre-quote RFQ:', error)
+ return {
+ success: false,
+ error: error instanceof Error ? error.message : '사전견적용 일반견적 생성에 실패했습니다',
+ }
+ }
+}
+
+// 일반견적 RFQ 코드 미리보기 (rfq-last/service에서 재사용)
+export async function previewGeneralRfqCode(picUserId: number): Promise<string> {
+ try {
+ const { previewGeneralRfqCode: previewCode } = await import('@/lib/rfq-last/service')
+ return await previewCode(picUserId)
+ } catch (error) {
+ console.error('Failed to preview general RFQ code:', error)
+ return 'F???00001'
+ }
+}
+
+// 내정가 산정 기준 업데이트
+export async function updateTargetPriceCalculationCriteria(
+ biddingId: number,
+ criteria: string,
+ userId: string
+) {
+ try {
+ const userName = await getUserNameById(userId)
+
+ await db
+ .update(biddings)
+ .set({
+ targetPriceCalculationCriteria: criteria.trim() || null,
+ updatedAt: new Date(),
+ updatedBy: userName,
+ })
+ .where(eq(biddings.id, biddingId))
+
+ revalidatePath(`/evcp/bid/${biddingId}`)
+ revalidatePath(`/evcp/bid/${biddingId}/items`)
+
+ return {
+ success: true,
+ message: '내정가 산정 기준이 성공적으로 저장되었습니다.',
+ }
+ } catch (error) {
+ console.error('Failed to update target price calculation criteria:', error)
+ return {
+ success: false,
+ error: '내정가 산정 기준 저장에 실패했습니다.',
+ }
+ }
+}
+
// 활성 템플릿 조회 서버 액션
export async function getActiveContractTemplates() {
try {
@@ -1443,4 +3073,829 @@ export async function searchVendorsForBidding(searchTerm: string = "", biddingId
console.error('Error searching vendors for bidding:', error)
return []
}
+}
+
+// 차수증가 또는 재입찰 함수
+export async function increaseRoundOrRebid(biddingId: number, userId: string, type: 'round_increase' | 'rebidding') {
+ 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. 입찰번호 파싱 및 차수 증가
+ const currentBiddingNumber = existingBidding.biddingNumber
+
+ // 현재 입찰번호에서 차수 추출 (예: E00025-02 -> 02)
+ const match = currentBiddingNumber.match(/-(\d+)$/)
+ let currentRound = match ? parseInt(match[1]) : 1
+
+ let newBiddingNumber: string
+
+ if (currentRound >= 3) {
+ // -03 이상이면 새로운 번호 생성
+ newBiddingNumber = await generateBiddingNumber(existingBidding.contractType, userId, tx)
+ } else {
+ // -02까지는 차수만 증가
+ const baseNumber = currentBiddingNumber.split('-')[0]
+ newBiddingNumber = `${baseNumber}-${String(currentRound + 1).padStart(2, '0')}`
+ }
+
+ // 3. 새로운 입찰 생성 (기존 정보 복제)
+ const [newBidding] = await tx
+ .insert(biddings)
+ .values({
+ biddingNumber: newBiddingNumber,
+ originalBiddingNumber: null, // 원입찰번호는 단순 정보이므로 null
+ revision: 0,
+ biddingSourceType: existingBidding.biddingSourceType,
+
+ // 기본 정보 복제
+ projectName: existingBidding.projectName,
+ itemName: existingBidding.itemName,
+ title: existingBidding.title,
+ description: existingBidding.description,
+
+ // 계약 정보 복제
+ contractType: existingBidding.contractType,
+ biddingType: existingBidding.biddingType,
+ awardCount: existingBidding.awardCount,
+ contractStartDate: existingBidding.contractStartDate,
+ contractEndDate: existingBidding.contractEndDate,
+
+ // 일정은 초기화 (새로 설정해야 함)
+ preQuoteDate: null,
+ biddingRegistrationDate: new Date(),
+ submissionStartDate: null,
+ submissionEndDate: null,
+ evaluationDate: null,
+
+ // 사양설명회
+ hasSpecificationMeeting: existingBidding.hasSpecificationMeeting,
+
+ // 예산 및 가격 정보 복제
+ currency: existingBidding.currency,
+ budget: existingBidding.budget,
+ targetPrice: existingBidding.targetPrice,
+ targetPriceCalculationCriteria: existingBidding.targetPriceCalculationCriteria,
+ finalBidPrice: null, // 최종입찰가는 초기화
+
+ // PR 정보 복제
+ prNumber: existingBidding.prNumber,
+ hasPrDocument: existingBidding.hasPrDocument,
+
+ // 상태는 내정가 산정으로 초기화
+ status: 'set_target_price',
+ isPublic: existingBidding.isPublic,
+ isUrgent: existingBidding.isUrgent,
+
+ // 구매조직
+ purchasingOrganization: existingBidding.purchasingOrganization,
+
+ // 담당자 정보 복제
+ bidPicId: existingBidding.bidPicId,
+ bidPicName: existingBidding.bidPicName,
+ bidPicCode: existingBidding.bidPicCode,
+ supplyPicId: existingBidding.supplyPicId,
+ supplyPicName: existingBidding.supplyPicName,
+ supplyPicCode: existingBidding.supplyPicCode,
+
+ remarks: `${type === 'round_increase' ? '차수증가' : '재입찰'}`,
+ createdBy: userName,
+ updatedBy: userName,
+ ANFNR: existingBidding.ANFNR,
+ })
+ .returning()
+
+ // 4. 입찰 조건 복제
+ const [existingConditions] = await tx
+ .select()
+ .from(biddingConditions)
+ .where(eq(biddingConditions.biddingId, biddingId))
+ .limit(1)
+
+ if (existingConditions) {
+ await tx
+ .insert(biddingConditions)
+ .values({
+ biddingId: newBidding.id,
+ paymentTerms: existingConditions.paymentTerms,
+ taxConditions: existingConditions.taxConditions,
+ incoterms: existingConditions.incoterms,
+ incotermsOption: existingConditions.incotermsOption,
+ contractDeliveryDate: existingConditions.contractDeliveryDate,
+ shippingPort: existingConditions.shippingPort,
+ destinationPort: existingConditions.destinationPort,
+ isPriceAdjustmentApplicable: existingConditions.isPriceAdjustmentApplicable,
+ sparePartOptions: existingConditions.sparePartOptions,
+ })
+ }
+
+ // 5. PR 아이템 복제
+ const existingPrItems = await tx
+ .select()
+ .from(prItemsForBidding)
+ .where(eq(prItemsForBidding.biddingId, biddingId))
+
+ if (existingPrItems.length > 0) {
+ await tx
+ .insert(prItemsForBidding)
+ .values(
+ existingPrItems.map((item) => ({
+ biddingId: newBidding.id,
+
+ // 기본 정보
+ itemNumber: item.itemNumber,
+ projectId: item.projectId,
+ projectInfo: item.projectInfo,
+ itemInfo: item.itemInfo,
+ shi: item.shi,
+ prNumber: item.prNumber,
+
+ // 자재 그룹 정보
+ materialGroupNumber: item.materialGroupNumber,
+ materialGroupInfo: item.materialGroupInfo,
+
+ // 자재 정보
+ materialNumber: item.materialNumber,
+ materialInfo: item.materialInfo,
+
+ // 납품 일정
+ requestedDeliveryDate: item.requestedDeliveryDate,
+
+ // 가격 정보
+ annualUnitPrice: item.annualUnitPrice,
+ currency: item.currency,
+
+ // 수량 및 중량
+ quantity: item.quantity,
+ quantityUnit: item.quantityUnit,
+ totalWeight: item.totalWeight,
+ weightUnit: item.weightUnit,
+
+ // 단위 정보
+ priceUnit: item.priceUnit,
+ purchaseUnit: item.purchaseUnit,
+ materialWeight: item.materialWeight,
+
+ // WBS 정보
+ wbsCode: item.wbsCode,
+ wbsName: item.wbsName,
+
+ // Cost Center 정보
+ costCenterCode: item.costCenterCode,
+ costCenterName: item.costCenterName,
+
+ // GL Account 정보
+ glAccountCode: item.glAccountCode,
+ glAccountName: item.glAccountName,
+
+ // 내정가 정보
+ targetUnitPrice: item.targetUnitPrice,
+ targetAmount: item.targetAmount,
+ targetCurrency: item.targetCurrency,
+
+ // 예산 정보
+ budgetAmount: item.budgetAmount,
+ budgetCurrency: item.budgetCurrency,
+
+ // 실적 정보
+ actualAmount: item.actualAmount,
+ actualCurrency: item.actualCurrency,
+
+ // SPEC 문서 여부
+ hasSpecDocument: item.hasSpecDocument,
+
+ createdAt: new Date(),
+ updatedAt: new Date(),
+ }))
+ )
+ }
+
+ // 6. 벤더 복제 (제출 정보는 초기화)
+ const existingCompanies = await tx
+ .select()
+ .from(biddingCompanies)
+ .where(eq(biddingCompanies.biddingId, biddingId))
+
+ if (existingCompanies.length > 0) {
+ await tx
+ .insert(biddingCompanies)
+ .values(
+ existingCompanies.map((company) => ({
+ biddingId: newBidding.id,
+ companyId: company.companyId,
+ invitedAt: new Date(),
+ invitationStatus: 'pending' as const, // 초대 대기 상태로 초기화
+ // 제출 정보는 초기화
+ submittedAt: null,
+ quotationPrice: null,
+ quotationCurrency: null,
+ quotationValidityDays: null,
+ deliveryDate: null,
+ remarks: null,
+ }))
+ )
+ }
+
+ // 7. 사양설명회 정보 복제 (있는 경우)
+ if (existingBidding.hasSpecificationMeeting) {
+ const [existingMeeting] = await tx
+ .select()
+ .from(specificationMeetings)
+ .where(eq(specificationMeetings.biddingId, biddingId))
+ .limit(1)
+
+ if (existingMeeting) {
+ await tx
+ .insert(specificationMeetings)
+ .values({
+ biddingId: newBidding.id,
+ meetingDate: existingMeeting.meetingDate,
+ meetingTime: existingMeeting.meetingTime,
+ location: existingMeeting.location,
+ address: existingMeeting.address,
+ contactPerson: existingMeeting.contactPerson,
+ contactPhone: existingMeeting.contactPhone,
+ contactEmail: existingMeeting.contactEmail,
+ agenda: existingMeeting.agenda,
+ materials: existingMeeting.materials,
+ notes: existingMeeting.notes,
+ isRequired: existingMeeting.isRequired,
+ })
+ }
+ }
+ // 8. 입찰공고문 정보 복제 (있는 경우)
+ if (existingBidding.hasBiddingNotice) {
+ const [existingNotice] = await tx
+ .select()
+ .from(biddingNoticeTemplate)
+ .where(eq(biddingNoticeTemplate.biddingId, biddingId))
+ .limit(1)
+
+ if (existingNotice) {
+ await tx
+ .insert(biddingNoticeTemplate)
+ .values({
+ biddingId: newBidding.id,
+ title: existingNotice.title,
+ content: existingNotice.content,
+ })
+ }
+ }
+
+ revalidatePath('/bidding')
+ revalidatePath(`/bidding/${newBidding.id}`)
+
+ return {
+ success: true,
+ message: `${type === 'round_increase' ? '차수증가' : '재입찰'}가 완료되었습니다.`,
+ biddingId: newBidding.id,
+ biddingNumber: newBiddingNumber
+ }
+ })
+ } catch (error) {
+ console.error('차수증가/재입찰 실패:', error)
+ return {
+ success: false,
+ error: error instanceof Error ? error.message : '차수증가/재입찰 중 오류가 발생했습니다.'
+ }
+ }
+}
+
+/**
+ * 벤더의 담당자 목록 조회
+ */
+export async function getVendorContactsByVendorId(vendorId: number) {
+ try {
+ const contacts = await db
+ .select({
+ id: vendorContacts.id,
+ vendorId: vendorContacts.vendorId,
+ contactName: vendorContacts.contactName,
+ contactPosition: vendorContacts.contactPosition,
+ contactDepartment: vendorContacts.contactDepartment,
+ contactTask: vendorContacts.contactTask,
+ contactEmail: vendorContacts.contactEmail,
+ contactPhone: vendorContacts.contactPhone,
+ isPrimary: vendorContacts.isPrimary,
+ createdAt: vendorContacts.createdAt,
+ updatedAt: vendorContacts.updatedAt,
+ })
+ .from(vendorContacts)
+ .where(eq(vendorContacts.vendorId, vendorId))
+ .orderBy(vendorContacts.isPrimary, vendorContacts.contactName)
+
+ return {
+ success: true,
+ data: contacts
+ }
+ } catch (error) {
+ console.error('Failed to get vendor contacts:', error)
+ return {
+ success: false,
+ error: error instanceof Error ? error.message : '담당자 목록 조회에 실패했습니다.'
+ }
+ }
+}
+
+// ═══════════════════════════════════════════════════════════════
+// bid-receive 페이지용 함수들
+// ═══════════════════════════════════════════════════════════════
+
+// bid-receive: 입찰서접수및마감 페이지용 입찰 목록 조회
+export async function getBiddingsForReceive(input: GetBiddingsSchema) {
+ try {
+ const offset = (input.page - 1) * input.perPage
+
+ // 기본 필터 조건들 (입찰서접수및마감에 적합한 상태만)
+ const basicConditions: SQL<unknown>[] = []
+
+ // 입찰서 접수 및 마감과 관련된 상태만 필터링
+ // 'received_quotation', 'bidding_opened', 'bidding_closed' 상태만 조회
+ basicConditions.push(
+ or(
+ eq(biddings.status, 'received_quotation'),
+ eq(biddings.status, 'bidding_opened'),
+ eq(biddings.status, 'bidding_closed')
+ )!
+ )
+
+ if (input.biddingNumber) {
+ basicConditions.push(ilike(biddings.biddingNumber, `%${input.biddingNumber}%`))
+ }
+
+ if (input.status && input.status.length > 0) {
+ basicConditions.push(
+ or(...input.status.map(status => eq(biddings.status, status)))!
+ )
+ }
+
+ if (input.contractType && input.contractType.length > 0) {
+ basicConditions.push(
+ or(...input.contractType.map(type => eq(biddings.contractType, type)))!
+ )
+ }
+
+ if (input.prNumber) {
+ basicConditions.push(ilike(biddings.prNumber, `%${input.prNumber}%`))
+ }
+
+ if (input.managerName) {
+ basicConditions.push(
+ or(
+ ilike(biddings.bidPicName, `%${input.managerName}%`),
+ ilike(biddings.supplyPicName, `%${input.managerName}%`)
+ )!
+ )
+ }
+
+ // 날짜 필터들
+ if (input.submissionDateFrom) {
+ basicConditions.push(gte(biddings.submissionStartDate, new Date(input.submissionDateFrom)))
+ }
+ if (input.submissionDateTo) {
+ basicConditions.push(lte(biddings.submissionEndDate, new Date(input.submissionDateTo)))
+ }
+
+ if (input.createdAtFrom) {
+ basicConditions.push(gte(biddings.createdAt, new Date(input.createdAtFrom)))
+ }
+ if (input.createdAtTo) {
+ basicConditions.push(lte(biddings.createdAt, new Date(input.createdAtTo)))
+ }
+
+ const basicWhere = basicConditions.length > 0 ? and(...basicConditions) : undefined
+
+ // 글로벌 검색 조건
+ let globalWhere: SQL<unknown> | undefined = undefined
+ if (input.search) {
+ const s = `%${input.search}%`
+ const searchConditions = [
+ ilike(biddings.biddingNumber, s),
+ ilike(biddings.title, s),
+ ilike(biddings.projectName, s),
+ ilike(biddings.prNumber, s),
+ ilike(biddings.bidPicName, s),
+ ilike(biddings.supplyPicName, s),
+ ]
+ globalWhere = or(...searchConditions)
+ }
+
+ const whereConditions: SQL<unknown>[] = []
+ if (basicWhere) whereConditions.push(basicWhere)
+ if (globalWhere) whereConditions.push(globalWhere)
+
+ const finalWhere = whereConditions.length > 0 ? and(...whereConditions) : undefined
+
+ // 전체 개수 조회
+ const totalResult = await db
+ .select({ count: count() })
+ .from(biddings)
+ .where(finalWhere)
+
+ const total = totalResult[0]?.count || 0
+
+ if (total === 0) {
+ return { data: [], pageCount: 0, total: 0 }
+ }
+
+ // 정렬
+ const orderByColumns = input.sort.map((sort) => {
+ const column = sort.id as keyof typeof biddings.$inferSelect
+ return sort.desc ? desc(biddings[column]) : asc(biddings[column])
+ })
+
+ if (orderByColumns.length === 0) {
+ orderByColumns.push(desc(biddings.createdAt))
+ }
+
+ // bid-receive 페이지용 데이터 조회 (필요한 컬럼만 선택)
+ const data = await db
+ .select({
+ // 기본 입찰 정보
+ id: biddings.id,
+ biddingNumber: biddings.biddingNumber,
+ originalBiddingNumber: biddings.originalBiddingNumber,
+ title: biddings.title,
+ status: biddings.status,
+ contractType: biddings.contractType,
+ prNumber: biddings.prNumber,
+ submissionStartDate: biddings.submissionStartDate,
+ submissionEndDate: biddings.submissionEndDate,
+ bidPicName: biddings.bidPicName,
+ supplyPicName: biddings.supplyPicName,
+ createdBy: biddings.createdBy,
+ createdAt: biddings.createdAt,
+ updatedAt: biddings.updatedAt,
+
+ // 참여 현황 집계
+ participantExpected: sql<number>`
+ COALESCE((
+ SELECT count(*)
+ FROM bidding_companies
+ WHERE bidding_id = ${biddings.id}
+ ), 0)
+ `.as('participant_expected'),
+
+ participantParticipated: sql<number>`
+ COALESCE((
+ SELECT count(*)
+ FROM bidding_companies
+ WHERE bidding_id = ${biddings.id}
+ AND invitation_status = 'bidding_submitted'
+ ), 0)
+ `.as('participant_participated'),
+
+ participantDeclined: sql<number>`
+ COALESCE((
+ SELECT count(*)
+ FROM bidding_companies
+ WHERE bidding_id = ${biddings.id}
+ AND invitation_status IN ('bidding_declined', 'bidding_cancelled')
+ ), 0)
+ `.as('participant_declined'),
+
+ participantPending: sql<number>`
+ COALESCE((
+ SELECT count(*)
+ FROM bidding_companies
+ WHERE bidding_id = ${biddings.id}
+ AND invitation_status IN ('pending', 'bidding_sent', 'bidding_accepted')
+ ), 0)
+ `.as('participant_pending'),
+
+ // 개찰 정보 (bidding_opened 상태에서만 의미 있음)
+ openedAt: biddings.updatedAt, // 개찰일은 업데이트 일시로 대체
+ openedBy: biddings.updatedBy, // 개찰자는 업데이트자로 대체
+ })
+ .from(biddings)
+ .where(finalWhere)
+ .orderBy(...orderByColumns)
+ .limit(input.perPage)
+ .offset(offset)
+
+ const pageCount = Math.ceil(total / input.perPage)
+
+ return { data, pageCount, total }
+
+ } catch (err) {
+ console.error("Error in getBiddingsForReceive:", err)
+ return { data: [], pageCount: 0, total: 0 }
+ }
+}
+
+// ═══════════════════════════════════════════════════════════════
+// bid-selection 페이지용 함수들
+// ═══════════════════════════════════════════════════════════════
+
+// bid-selection: 개찰 이후 입찰가 및 정보 확인 페이지용 입찰 목록 조회
+export async function getBiddingsForSelection(input: GetBiddingsSchema) {
+ try {
+ const offset = (input.page - 1) * input.perPage
+
+ // 기본 필터 조건들 (개찰 이후 상태만)
+ const basicConditions: SQL<unknown>[] = []
+
+ // 개찰 이후 상태만 필터링
+ // 'bidding_opened', 'bidding_closed', 'evaluation_of_bidding', 'vendor_selected' 상태만 조회
+ basicConditions.push(
+ or(
+ eq(biddings.status, 'bidding_opened'),
+ eq(biddings.status, 'bidding_closed'),
+ eq(biddings.status, 'evaluation_of_bidding'),
+ eq(biddings.status, 'vendor_selected')
+ )!
+ )
+
+ if (input.biddingNumber) {
+ basicConditions.push(ilike(biddings.biddingNumber, `%${input.biddingNumber}%`))
+ }
+
+ if (input.status && input.status.length > 0) {
+ basicConditions.push(
+ or(...input.status.map(status => eq(biddings.status, status)))!
+ )
+ }
+
+ if (input.contractType && input.contractType.length > 0) {
+ basicConditions.push(
+ or(...input.contractType.map(type => eq(biddings.contractType, type)))!
+ )
+ }
+
+ if (input.prNumber) {
+ basicConditions.push(ilike(biddings.prNumber, `%${input.prNumber}%`))
+ }
+
+ if (input.managerName) {
+ basicConditions.push(
+ or(
+ ilike(biddings.bidPicName, `%${input.managerName}%`),
+ ilike(biddings.supplyPicName, `%${input.managerName}%`)
+ )!
+ )
+ }
+
+ // 날짜 필터들
+ if (input.submissionDateFrom) {
+ basicConditions.push(gte(biddings.submissionStartDate, new Date(input.submissionDateFrom)))
+ }
+ if (input.submissionDateTo) {
+ basicConditions.push(lte(biddings.submissionEndDate, new Date(input.submissionDateTo)))
+ }
+
+ if (input.createdAtFrom) {
+ basicConditions.push(gte(biddings.createdAt, new Date(input.createdAtFrom)))
+ }
+ if (input.createdAtTo) {
+ basicConditions.push(lte(biddings.createdAt, new Date(input.createdAtTo)))
+ }
+
+ const basicWhere = basicConditions.length > 0 ? and(...basicConditions) : undefined
+
+ // 글로벌 검색 조건
+ let globalWhere: SQL<unknown> | undefined = undefined
+ if (input.search) {
+ const s = `%${input.search}%`
+ const searchConditions = [
+ ilike(biddings.biddingNumber, s),
+ ilike(biddings.title, s),
+ ilike(biddings.projectName, s),
+ ilike(biddings.prNumber, s),
+ ilike(biddings.bidPicName, s),
+ ilike(biddings.supplyPicName, s),
+ ]
+ globalWhere = or(...searchConditions)
+ }
+
+ const whereConditions: SQL<unknown>[] = []
+ if (basicWhere) whereConditions.push(basicWhere)
+ if (globalWhere) whereConditions.push(globalWhere)
+
+ const finalWhere = whereConditions.length > 0 ? and(...whereConditions) : undefined
+
+ // 전체 개수 조회
+ const totalResult = await db
+ .select({ count: count() })
+ .from(biddings)
+ .where(finalWhere)
+
+ const total = totalResult[0]?.count || 0
+
+ if (total === 0) {
+ return { data: [], pageCount: 0, total: 0 }
+ }
+
+ // 정렬
+ const orderByColumns = input.sort.map((sort) => {
+ const column = sort.id as keyof typeof biddings.$inferSelect
+ return sort.desc ? desc(biddings[column]) : asc(biddings[column])
+ })
+
+ if (orderByColumns.length === 0) {
+ orderByColumns.push(desc(biddings.createdAt))
+ }
+
+ // bid-selection 페이지용 데이터 조회
+ const data = await db
+ .select({
+ // 기본 입찰 정보
+ id: biddings.id,
+ biddingNumber: biddings.biddingNumber,
+ originalBiddingNumber: biddings.originalBiddingNumber,
+ title: biddings.title,
+ status: biddings.status,
+ contractType: biddings.contractType,
+ prNumber: biddings.prNumber,
+ submissionStartDate: biddings.submissionStartDate,
+ submissionEndDate: biddings.submissionEndDate,
+ bidPicName: biddings.bidPicName,
+ supplyPicName: biddings.supplyPicName,
+ createdBy: biddings.createdBy,
+ createdAt: biddings.createdAt,
+ updatedAt: biddings.updatedAt,
+ })
+ .from(biddings)
+ .where(finalWhere)
+ .orderBy(...orderByColumns)
+ .limit(input.perPage)
+ .offset(offset)
+
+ const pageCount = Math.ceil(total / input.perPage)
+
+ return { data, pageCount, total }
+
+ } catch (err) {
+ console.error("Error in getBiddingsForSelection:", err)
+ return { data: [], pageCount: 0, total: 0 }
+ }
+}
+
+// ═══════════════════════════════════════════════════════════════
+// bid-failure 페이지용 함수들
+// ═══════════════════════════════════════════════════════════════
+
+// bid-failure: 유찰된 입찰 확인 페이지용 입찰 목록 조회
+export async function getBiddingsForFailure(input: GetBiddingsSchema) {
+ try {
+ const offset = (input.page - 1) * input.perPage
+
+ // 기본 필터 조건들 (유찰된 입찰만)
+ const basicConditions: SQL<unknown>[] = []
+
+ // 유찰된 상태만 필터링
+ basicConditions.push(eq(biddings.status, 'bidding_disposal'))
+
+ if (input.biddingNumber) {
+ basicConditions.push(ilike(biddings.biddingNumber, `%${input.biddingNumber}%`))
+ }
+
+ if (input.status && input.status.length > 0) {
+ basicConditions.push(
+ or(...input.status.map(status => eq(biddings.status, status)))!
+ )
+ }
+
+ if (input.contractType && input.contractType.length > 0) {
+ basicConditions.push(
+ or(...input.contractType.map(type => eq(biddings.contractType, type)))!
+ )
+ }
+
+ if (input.prNumber) {
+ basicConditions.push(ilike(biddings.prNumber, `%${input.prNumber}%`))
+ }
+
+ if (input.managerName) {
+ basicConditions.push(
+ or(
+ ilike(biddings.bidPicName, `%${input.managerName}%`),
+ ilike(biddings.supplyPicName, `%${input.managerName}%`)
+ )!
+ )
+ }
+
+ // 날짜 필터들
+ if (input.createdAtFrom) {
+ basicConditions.push(gte(biddings.createdAt, new Date(input.createdAtFrom)))
+ }
+ if (input.createdAtTo) {
+ basicConditions.push(lte(biddings.createdAt, new Date(input.createdAtTo)))
+ }
+
+ if (input.submissionDateFrom) {
+ basicConditions.push(gte(biddings.submissionStartDate, new Date(input.submissionDateFrom)))
+ }
+ if (input.submissionDateTo) {
+ basicConditions.push(lte(biddings.submissionEndDate, new Date(input.submissionDateTo)))
+ }
+
+ const basicWhere = basicConditions.length > 0 ? and(...basicConditions) : undefined
+
+ // 글로벌 검색 조건
+ let globalWhere: SQL<unknown> | undefined = undefined
+ if (input.search) {
+ const s = `%${input.search}%`
+ const searchConditions = [
+ ilike(biddings.biddingNumber, s),
+ ilike(biddings.title, s),
+ ilike(biddings.projectName, s),
+ ilike(biddings.prNumber, s),
+ ilike(biddings.bidPicName, s),
+ ilike(biddings.supplyPicName, s),
+ ]
+ globalWhere = or(...searchConditions)
+ }
+
+ const whereConditions: SQL<unknown>[] = []
+ if (basicWhere) whereConditions.push(basicWhere)
+ if (globalWhere) whereConditions.push(globalWhere)
+
+ const finalWhere = whereConditions.length > 0 ? and(...whereConditions) : undefined
+
+ // 전체 개수 조회
+ const totalResult = await db
+ .select({ count: count() })
+ .from(biddings)
+ .where(finalWhere)
+
+ const total = totalResult[0]?.count || 0
+
+ if (total === 0) {
+ return { data: [], pageCount: 0, total: 0 }
+ }
+
+ // 정렬
+ const orderByColumns = input.sort.map((sort) => {
+ const column = sort.id as keyof typeof biddings.$inferSelect
+ return sort.desc ? desc(biddings[column]) : asc(biddings[column])
+ })
+
+ if (orderByColumns.length === 0) {
+ orderByColumns.push(desc(biddings.updatedAt)) // 유찰된 최신순
+ }
+
+ // bid-failure 페이지용 데이터 조회
+ const data = await db
+ .select({
+ // 기본 입찰 정보
+ id: biddings.id,
+ biddingNumber: biddings.biddingNumber,
+ originalBiddingNumber: biddings.originalBiddingNumber,
+ title: biddings.title,
+ status: biddings.status,
+ contractType: biddings.contractType,
+ prNumber: biddings.prNumber,
+
+ // 가격 정보
+ targetPrice: biddings.targetPrice,
+ currency: biddings.currency,
+
+ // 일정 정보
+ biddingRegistrationDate: biddings.biddingRegistrationDate,
+ submissionStartDate: biddings.submissionStartDate,
+ submissionEndDate: biddings.submissionEndDate,
+
+ // 담당자 정보
+ bidPicName: biddings.bidPicName,
+ supplyPicName: biddings.supplyPicName,
+
+ // 유찰 정보 (업데이트 일시를 유찰일로 사용)
+ disposalDate: biddings.updatedAt, // 유찰일
+ disposalUpdatedAt: biddings.updatedAt, // 폐찰수정일
+ disposalUpdatedBy: biddings.updatedBy, // 폐찰수정자
+
+ // 기타 정보
+ createdBy: biddings.createdBy,
+ createdAt: biddings.createdAt,
+ updatedAt: biddings.updatedAt,
+ updatedBy: biddings.updatedBy,
+ })
+ .from(biddings)
+ .where(finalWhere)
+ .orderBy(...orderByColumns)
+ .limit(input.perPage)
+ .offset(offset)
+
+ const pageCount = Math.ceil(total / input.perPage)
+
+ return { data, pageCount, total }
+
+ } catch (err) {
+ console.error("Error in getBiddingsForFailure:", err)
+ return { data: [], pageCount: 0, total: 0 }
+ }
} \ No newline at end of file
diff --git a/lib/bidding/validation.ts b/lib/bidding/validation.ts
index 8476be1c..5cf296e1 100644
--- a/lib/bidding/validation.ts
+++ b/lib/bidding/validation.ts
@@ -1,4 +1,4 @@
-import { BiddingListView, biddings } from "@/db/schema"
+import { BiddingListItem, biddings } from "@/db/schema"
import {
createSearchParamsCache,
parseAsArrayOf,
@@ -14,7 +14,7 @@ export const searchParamsCache = createSearchParamsCache({
flags: parseAsArrayOf(z.enum(["advancedTable", "floatingBar"])).withDefault([]),
page: parseAsInteger.withDefault(1),
perPage: parseAsInteger.withDefault(10),
- sort: getSortingStateParser<BiddingListView>().withDefault([
+ sort: getSortingStateParser<BiddingListItem>().withDefault([
{ id: "createdAt", desc: true },
]),
@@ -23,6 +23,7 @@ export const searchParamsCache = createSearchParamsCache({
status: parseAsArrayOf(z.enum(biddings.status.enumValues)).withDefault([]),
biddingType: parseAsArrayOf(z.enum(biddings.biddingType.enumValues)).withDefault([]),
contractType: parseAsArrayOf(z.enum(biddings.contractType.enumValues)).withDefault([]),
+ purchasingOrganization: parseAsString.withDefault(""),
managerName: parseAsString.withDefault(""),
// 날짜 필터
@@ -51,19 +52,24 @@ export const createBiddingSchema = z.object({
// ❌ 제거: biddingNumber (자동 생성)
// ❌ 제거: preQuoteDate (나중에 자동 기록)
// ❌ 제거: biddingRegistrationDate (시스템에서 자동 기록)
-
+
revision: z.number().int().min(0).default(0),
-
- // ✅ 프로젝트 정보 (새로 추가)
- projectId: z.number().min(1, "프로젝트를 선택해주세요"), // 필수
+
+ // ✅ 프로젝트 정보 (새로 추가) - 임시로 optional로 변경
+ projectId: z.number().optional(), // 임시로 필수 해제
projectName: z.string().optional(), // ProjectSelector에서 자동 설정
-
+
// ✅ 필수 필드들
- itemName: z.string().min(1, "품목명은 필수입니다"),
+ itemName: z.string().optional(), // 임시로 필수 해제
title: z.string().min(1, "입찰명은 필수입니다"),
description: z.string().optional(),
content: z.string().optional(),
-
+
+ // 입찰공고 정보
+ noticeType: z.enum(['standard', 'facility', 'unit_price'], {
+ required_error: "입찰공고 타입을 선택해주세요"
+ }),
+
// ✅ 계약 정보 (필수)
contractType: z.enum(biddings.contractType.enumValues, {
required_error: "계약구분을 선택해주세요"
@@ -75,44 +81,90 @@ export const createBiddingSchema = z.object({
awardCount: z.enum(biddings.awardCount.enumValues, {
required_error: "낙찰수를 선택해주세요"
}),
+
+ // ✅ 가격 정보 (조회용으로 readonly 처리)
+ budget: z.string().optional(), // 예산 (조회용)
+ finalBidPrice: z.string().optional(), // 실적가 (조회용)
+ targetPrice: z.string().optional(), // 내정가 (조회용)
+
+ // PR 정보 (조회용)
+ prNumber: z.string().optional(),
+
+ // 계약기간
contractStartDate: z.string().optional(),
contractEndDate: z.string().optional(),
-
+
// ✅ 일정 (제출기간 필수)
- submissionStartDate: z.string().min(1, "제출시작일시는 필수입니다"),
- submissionEndDate: z.string().min(1, "제출마감일시는 필수입니다"),
+ submissionStartDate: z.string().optional(),
+
+ submissionEndDate: z.string().optional(),
+
evaluationDate: z.string().optional(),
-
+
// 회의 및 문서
hasSpecificationMeeting: z.boolean().default(false),
hasPrDocument: z.boolean().default(false),
- prNumber: z.string().optional(),
-
+
// ✅ 가격 정보 (통화 필수)
currency: z.string().min(1, "통화를 선택해주세요").default("KRW"),
-
- // 상태 및 담당자
+
+ // 상태 (조회용)
status: z.enum(biddings.status.enumValues).default("bidding_generated"),
isPublic: z.boolean().default(false),
isUrgent: z.boolean().default(false),
+
+ // 구매조직
+ purchasingOrganization: z.string().optional(),
+
+ // 담당자 정보 (개선된 구조)
+ bidPicId: z.number().int().positive().optional(),
+ bidPicName: z.string().min(1, "입찰담당자는 필수입니다"),
+ bidPicCode: z.string().min(1, "입찰담당자 코드는 필수입니다"),
+ supplyPicId: z.number().int().positive().optional(),
+ supplyPicName: z.string().optional(),
+ supplyPicCode: z.string().optional(),
+
+ // 기존 담당자 정보 (점진적 마이그레이션을 위해 유지)
managerName: z.string().optional(),
managerEmail: z.string().email().optional().or(z.literal("")),
managerPhone: z.string().optional(),
-
+
+ // 구매요청자 (현재 사용자)
+ requesterName: z.string().optional(),
+
// 메타
remarks: z.string().optional(),
- // 입찰 조건 (선택사항이지만, 설정할 경우 필수 항목들이 있음)
+ // 첨부파일 (두 가지 타입으로 구분)
+ attachments: z.array(z.object({
+ id: z.string(),
+ fileName: z.string(),
+ fileSize: z.number().optional(),
+ filePath: z.string(),
+ uploadedAt: z.string().optional(),
+ type: z.enum(['shi', 'vendor']).default('shi'), // SHI용 또는 협력업체용
+ })).default([]),
+ vendorAttachments: z.array(z.object({
+ id: z.string(),
+ fileName: z.string(),
+ fileSize: z.number().optional(),
+ filePath: z.string(),
+ uploadedAt: z.string().optional(),
+ type: z.enum(['shi', 'vendor']).default('vendor'), // SHI용 또는 협력업체용
+ })).default([]),
+
+ // 입찰 조건 (통합된 구조)
biddingConditions: z.object({
- paymentTerms: z.string().min(1, "지급조건은 필수입니다"),
- taxConditions: z.string().min(1, "세금조건은 필수입니다"),
- incoterms: z.string().min(1, "운송조건은 필수입니다"),
- contractDeliveryDate: z.string().min(1, "계약납품일은 필수입니다"),
- shippingPort: z.string().optional(),
- destinationPort: z.string().optional(),
- isPriceAdjustmentApplicable: z.boolean().default(false),
+ paymentTerms: z.string().min(1, "SHI 지급조건은 필수입니다"), // SHI 지급조건
+ taxConditions: z.string().min(1, "SHI 매입부가가치세는 필수입니다"), // SHI 매입부가가치세
+ incoterms: z.string().min(1, "SHI 인도조건은 필수입니다"), // SHI 인도조건
+ incotermsOption: z.string().optional(), // SHI 인도조건2
+ contractDeliveryDate: z.string().optional(),
+ shippingPort: z.string().optional(), // SHI 선적지
+ destinationPort: z.string().optional(), // SHI 하역지
+ isPriceAdjustmentApplicable: z.boolean().default(false), // 하도급법적용여부
sparePartOptions: z.string().optional(),
- }).optional(),
+ }),
}).refine((data) => {
// 제출 기간 검증: 시작일이 마감일보다 이전이어야 함
if (data.submissionStartDate && data.submissionEndDate) {
@@ -138,40 +190,78 @@ export const createBiddingSchema = z.object({
export const updateBiddingSchema = z.object({
biddingNumber: z.string().min(1, "입찰번호는 필수입니다").optional(),
revision: z.number().int().min(0).optional(),
-
+
projectId: z.number().min(1).optional(),
projectName: z.string().optional(),
itemName: z.string().min(1, "품목명은 필수입니다").optional(),
title: z.string().min(1, "입찰명은 필수입니다").optional(),
description: z.string().optional(),
content: z.string().optional(),
-
+
+ // 입찰공고 정보
+ noticeType: z.enum(['standard', 'facility', 'unit_price']).optional(),
+
contractType: z.enum(biddings.contractType.enumValues).optional(),
biddingType: z.enum(biddings.biddingType.enumValues).optional(),
+ biddingTypeCustom: z.string().optional(),
awardCount: z.enum(biddings.awardCount.enumValues).optional(),
+
+ // 가격 정보 (조회용)
+ budget: z.string().optional(),
+ finalBidPrice: z.string().optional(),
+ targetPrice: z.string().optional(),
+
+ // PR 정보 (조회용)
+ prNumber: z.string().optional(),
+
+ // 계약기간
contractStartDate: z.string().optional(),
contractEndDate: z.string().optional(),
-
+
submissionStartDate: z.string().optional(),
submissionEndDate: z.string().optional(),
evaluationDate: z.string().optional(),
-
+
hasSpecificationMeeting: z.boolean().optional(),
hasPrDocument: z.boolean().optional(),
- prNumber: z.string().optional(),
-
+
currency: z.string().optional(),
- budget: z.string().optional(),
- targetPrice: z.string().optional(),
- finalBidPrice: z.string().optional(),
-
status: z.enum(biddings.status.enumValues).optional(),
isPublic: z.boolean().optional(),
isUrgent: z.boolean().optional(),
+
+ // 구매조직
+ purchasingOrganization: z.string().optional(),
+
+ // 담당자 정보 (개선된 구조)
+ bidPicId: z.number().int().positive().optional(),
+ bidPicName: z.string().min(1, "입찰담당자는 필수입니다").optional(),
+ bidPicCode: z.string().min(1, "입찰담당자 코드는 필수입니다").optional(),
+ supplyPicId: z.number().int().positive().optional(),
+ supplyPicName: z.string().optional(),
+ supplyPicCode: z.string().optional(),
+
+ // 기존 담당자 정보 (점진적 마이그레이션을 위해 유지)
managerName: z.string().optional(),
managerEmail: z.string().email().optional().or(z.literal("")),
managerPhone: z.string().optional(),
-
+
+ // 구매요청자 (현재 사용자)
+ requesterName: z.string().optional(),
+
+ // 입찰 조건
+ biddingConditions: z.object({
+ paymentTerms: z.string().min(1, "SHI 지급조건은 필수입니다").optional(),
+ taxConditions: z.string().min(1, "SHI 매입부가가치세는 필수입니다").optional(),
+ incoterms: z.string().min(1, "SHI 인도조건은 필수입니다").optional(),
+ incotermsOption: z.string().optional(),
+ contractDeliveryDate: z.string().optional(),
+ shippingPort: z.string().optional(),
+ destinationPort: z.string().optional(),
+ isPriceAdjustmentApplicable: z.boolean().default(false),
+ sparePartOptions: z.string().optional(),
+ }),
+
remarks: z.string().optional(),
})
@@ -202,7 +292,7 @@ export const createBiddingSchema = z.object({
taxConditions: z.string().optional(),
deliveryDate: z.string().optional(),
awardRatio: z.number().min(0).max(100, '발주비율은 0-100 사이여야 합니다').optional(),
- status: z.enum(['pending', 'submitted', 'selected', 'rejected']).default('pending'),
+ invitationStatus: z.enum(['pending', 'pre_quote_sent', 'pre_quote_accepted', 'pre_quote_declined', 'pre_quote_submitted', 'bidding_sent', 'bidding_accepted', 'bidding_declined', 'bidding_cancelled', 'bidding_submitted']).default('pending'),
})
// 협력업체 정보 업데이트 스키마
@@ -219,7 +309,7 @@ export const createBiddingSchema = z.object({
taxConditions: z.string().optional(),
deliveryDate: z.string().optional(),
awardRatio: z.number().min(0).max(100, '발주비율은 0-100 사이여야 합니다').optional(),
- status: z.enum(['pending', 'submitted', 'selected', 'rejected']).optional(),
+ invitationStatus: z.enum(['pending', 'pre_quote_sent', 'pre_quote_accepted', 'pre_quote_declined', 'pre_quote_submitted', 'bidding_sent', 'bidding_accepted', 'bidding_declined', 'bidding_cancelled', 'bidding_submitted']).optional(),
})
// 낙찰 선택 스키마
diff --git a/lib/bidding/vendor/components/pr-items-pricing-table.tsx b/lib/bidding/vendor/components/pr-items-pricing-table.tsx
index 483bce5c..efa10af2 100644
--- a/lib/bidding/vendor/components/pr-items-pricing-table.tsx
+++ b/lib/bidding/vendor/components/pr-items-pricing-table.tsx
@@ -32,16 +32,27 @@ import {
interface PrItem {
id: number
+ biddingId: number
itemNumber: string | null
- prNumber: string | null
+ projectId: number | null
+ projectInfo: string | null
itemInfo: string | null
- materialDescription: string | null
+ shi: string | null
+ materialGroupNumber: string | null
+ materialGroupInfo: string | null
+ materialNumber: string | null
+ materialInfo: string | null
+ requestedDeliveryDate: Date | null
+ annualUnitPrice: string | null
+ currency: string | null
quantity: string | null
quantityUnit: string | null
totalWeight: string | null
weightUnit: string | null
- currency: string | null
- requestedDeliveryDate: string | null
+ priceUnit: string | null
+ purchaseUnit: string | null
+ materialWeight: string | null
+ prNumber: string | null
hasSpecDocument: boolean | null
}
@@ -283,8 +294,10 @@ export function PrItemsPricingTable({
<TableHead>자재내역</TableHead>
<TableHead>수량</TableHead>
<TableHead>단위</TableHead>
+ <TableHead>구매단위</TableHead>
<TableHead>중량</TableHead>
<TableHead>중량단위</TableHead>
+ <TableHead>가격단위</TableHead>
<TableHead>SHI 납품요청일</TableHead>
<TableHead>견적단가</TableHead>
<TableHead>견적금액</TableHead>
@@ -315,18 +328,20 @@ export function PrItemsPricingTable({
</div>
</TableCell>
<TableCell>
- <div className="max-w-32 truncate" title={item.materialDescription || ''}>
- {item.materialDescription || '-'}
+ <div className="max-w-32 truncate" title={item.materialInfo || ''}>
+ {item.materialInfo || '-'}
</div>
</TableCell>
<TableCell className="text-right">
{item.quantity ? parseFloat(item.quantity).toLocaleString() : '-'}
</TableCell>
<TableCell>{item.quantityUnit || '-'}</TableCell>
+ <TableCell>{item.purchaseUnit || '-'}</TableCell>
<TableCell className="text-right">
{item.totalWeight ? parseFloat(item.totalWeight).toLocaleString() : '-'}
</TableCell>
<TableCell>{item.weightUnit || '-'}</TableCell>
+ <TableCell>{item.priceUnit || '-'}</TableCell>
<TableCell>
{item.requestedDeliveryDate ?
formatDate(item.requestedDeliveryDate, 'KR') : '-'
diff --git a/lib/bidding/vendor/partners-bidding-detail.tsx b/lib/bidding/vendor/partners-bidding-detail.tsx
index f9241f7b..273c0667 100644
--- a/lib/bidding/vendor/partners-bidding-detail.tsx
+++ b/lib/bidding/vendor/partners-bidding-detail.tsx
@@ -1,11 +1,16 @@
'use client'
import * as React from 'react'
+
+// 브라우저 환경 체크
+const isBrowser = typeof window !== 'undefined'
import { useRouter } from 'next/navigation'
+import { useTransition } from 'react'
import { Button } from '@/components/ui/button'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Badge } from '@/components/ui/badge'
import { Label } from '@/components/ui/label'
+import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible'
import {
ArrowLeft,
User,
@@ -15,8 +20,10 @@ import {
XCircle,
Save,
FileText,
- Building2,
- Package
+ Package,
+ Trash2,
+ Calendar,
+ ChevronDown
} from 'lucide-react'
import { formatDate } from '@/lib/utils'
@@ -24,19 +31,26 @@ import {
getBiddingDetailsForPartners,
submitPartnerResponse,
updatePartnerBiddingParticipation,
- saveBiddingDraft
+ saveBiddingDraft,
+ getPriceAdjustmentFormByBiddingCompanyId
} from '../detail/service'
+import { cancelBiddingResponse } from '../detail/bidding-actions'
import { getPrItemsForBidding, getSavedPrItemQuotations } from '@/lib/bidding/pre-quote/service'
+import { getBiddingConditions } from '@/lib/bidding/service'
import { PrItemsPricingTable } from './components/pr-items-pricing-table'
import { SimpleFileUpload } from './components/simple-file-upload'
+import { getTaxConditionName } from "@/lib/tax-conditions/types"
import {
biddingStatusLabels,
contractTypeLabels,
biddingTypeLabels
} from '@/db/schema'
import { useToast } from '@/hooks/use-toast'
-import { useTransition } from 'react'
import { useSession } from 'next-auth/react'
+import { getBiddingNotice } from '../service'
+import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group'
+import { Input } from '@/components/ui/input'
+import { Textarea } from '@/components/ui/textarea'
interface PartnersBiddingDetailProps {
biddingId: number
@@ -51,7 +65,6 @@ interface BiddingDetail {
itemName: string | null
title: string
description: string | null
- content: string | null
contractType: string
biddingType: string
awardCount: string | null
@@ -66,33 +79,46 @@ interface BiddingDetail {
budget: number | null
targetPrice: number | null
status: string
- managerName: string | null
- managerEmail: string | null
- managerPhone: string | null
+ bidPicName: string | null // 입찰담당자
+ supplyPicName: string | null // 조달담당자
biddingCompanyId: number
biddingId: number
invitationStatus: string
finalQuoteAmount: number | null
finalQuoteSubmittedAt: Date | null
+ isFinalSubmission: boolean
isWinner: boolean
isAttendingMeeting: boolean | null
isBiddingParticipated: boolean | null
additionalProposals: string | null
responseSubmittedAt: Date | null
+ priceAdjustmentResponse: boolean | null // 연동제 적용 여부
+ isPreQuoteParticipated: boolean | null // 사전견적 참여 여부
}
interface PrItem {
id: number
+ biddingId: number
itemNumber: string | null
- prNumber: string | null
+ projectId: number | null
+ projectInfo: string | null
itemInfo: string | null
- materialDescription: string | null
+ shi: string | null
+ materialGroupNumber: string | null
+ materialGroupInfo: string | null
+ materialNumber: string | null
+ materialInfo: string | null
+ requestedDeliveryDate: Date | null
+ annualUnitPrice: string | null
+ currency: string | null
quantity: string | null
quantityUnit: string | null
totalWeight: string | null
weightUnit: string | null
- currency: string | null
- requestedDeliveryDate: string | null
+ priceUnit: string | null
+ purchaseUnit: string | null
+ materialWeight: string | null
+ prNumber: string | null
hasSpecDocument: boolean | null
}
@@ -104,6 +130,22 @@ interface BiddingPrItemQuotation {
technicalSpecification?: string
}
+interface BiddingConditions {
+ id?: number
+ biddingId?: number
+ paymentTerms?: string
+ taxConditions?: string
+ incoterms?: string
+ incotermsOption?: string
+ contractDeliveryDate?: string
+ shippingPort?: string
+ destinationPort?: string
+ isPriceAdjustmentApplicable?: boolean
+ sparePartOptions?: string
+ createdAt?: string
+ updatedAt?: string
+}
+
export function PartnersBiddingDetail({ biddingId, companyId }: PartnersBiddingDetailProps) {
const router = useRouter()
const { toast } = useToast()
@@ -114,7 +156,23 @@ export function PartnersBiddingDetail({ biddingId, companyId }: PartnersBiddingD
const [isUpdatingParticipation, setIsUpdatingParticipation] = React.useState(false)
const [isSavingDraft, setIsSavingDraft] = React.useState(false)
const [isSubmitting, setIsSubmitting] = React.useState(false)
-
+ const [isCancelling, setIsCancelling] = React.useState(false)
+ const [isFinalSubmission, setIsFinalSubmission] = React.useState(false)
+ const [isBiddingNoticeLoading, setIsBiddingNoticeLoading] = React.useState(false)
+
+ // 입찰공고 관련 상태
+ const [biddingNotice, setBiddingNotice] = React.useState<{
+ id?: number
+ biddingId?: number
+ title?: string
+ content?: string
+ isTemplate?: boolean
+ createdAt?: string
+ updatedAt?: string
+ } | null>(null)
+ const [biddingConditions, setBiddingConditions] = React.useState<BiddingConditions | null>(null)
+ const [isNoticeOpen, setIsNoticeOpen] = React.useState(false)
+
// 품목별 견적 관련 상태
const [prItems, setPrItems] = React.useState<PrItem[]>([])
const [prItemQuotations, setPrItemQuotations] = React.useState<BiddingPrItemQuotation[]>([])
@@ -125,21 +183,95 @@ export function PartnersBiddingDetail({ biddingId, companyId }: PartnersBiddingD
finalQuoteAmount: '',
proposedContractDeliveryDate: '',
additionalProposals: '',
+ priceAdjustmentResponse: null as boolean | null, // null: 미선택, true: 적용, false: 미적용
+ })
+
+ // 연동제 폼 상태
+ const [priceAdjustmentForm, setPriceAdjustmentForm] = React.useState({
+ itemName: '',
+ adjustmentReflectionPoint: '',
+ majorApplicableRawMaterial: '',
+ adjustmentFormula: '',
+ rawMaterialPriceIndex: '',
+ referenceDate: '',
+ comparisonDate: '',
+ adjustmentRatio: '',
+ notes: '',
+ adjustmentConditions: '',
+ majorNonApplicableRawMaterial: '',
+ adjustmentPeriod: '',
+ contractorWriter: '',
+ adjustmentDate: '',
+ nonApplicableReason: '', // 연동제 미희망 사유
})
const userId = session.data?.user?.id || ''
// 데이터 로드
+ // 입찰공고 로드 함수
+ const loadBiddingNotice = React.useCallback(async () => {
+ setIsBiddingNoticeLoading(true)
+ try {
+ const notice = await getBiddingNotice(biddingId)
+ console.log('입찰공고 로드 성공:', notice)
+ setBiddingNotice(notice)
+ } catch (error) {
+ console.error('Failed to load bidding notice:', error)
+ } finally {
+ setIsBiddingNoticeLoading(false)
+ }
+ }, [biddingId])
+
React.useEffect(() => {
const loadData = async () => {
try {
setIsLoading(true)
- const [result, prItemsResult] = await Promise.all([
- getBiddingDetailsForPartners(biddingId, companyId),
- getPrItemsForBidding(biddingId)
+ // 데이터 로드 시작 로그
+ console.log('입찰 상세 데이터 로드 시작:', { biddingId, companyId })
+
+ console.log('데이터베이스 쿼리 시작...')
+
+ const [result, prItemsResult, noticeResult, conditionsResult] = await Promise.all([
+ getBiddingDetailsForPartners(biddingId, companyId).catch(error => {
+ console.error('Failed to get bidding details:', error)
+ return null
+ }),
+ getPrItemsForBidding(biddingId).catch(error => {
+ console.error('Failed to get PR items:', error)
+ return []
+ }),
+ loadBiddingNotice().catch(error => {
+ console.error('Failed to load bidding notice:', error)
+ return null
+ }),
+ getBiddingConditions(biddingId).catch(error => {
+ console.error('Failed to load bidding conditions:', error)
+ return null
+ })
])
-
+
+ console.log('데이터베이스 쿼리 완료:', {
+ resultExists: !!result,
+ prItemsExists: !!prItemsResult,
+ noticeExists: !!noticeResult,
+ conditionsExists: !!conditionsResult
+ })
+
+ console.log('데이터 로드 완료:', {
+ result: !!result,
+ prItemsCount: Array.isArray(prItemsResult) ? prItemsResult.length : 0,
+ noticeResult: !!noticeResult,
+ conditionsResult: !!conditionsResult
+ })
+
if (result) {
+ console.log('입찰 상세 데이터 로드 성공:', {
+ biddingId: result.biddingId,
+ isBiddingParticipated: result.isBiddingParticipated,
+ invitationStatus: result.invitationStatus,
+ finalQuoteAmount: result.finalQuoteAmount
+ })
+
setBiddingDetail(result)
// 기존 응답 데이터로 폼 초기화
@@ -147,7 +279,14 @@ export function PartnersBiddingDetail({ biddingId, companyId }: PartnersBiddingD
finalQuoteAmount: result.finalQuoteAmount?.toString() || '',
proposedContractDeliveryDate: result.proposedContractDeliveryDate || '',
additionalProposals: result.additionalProposals || '',
+ priceAdjustmentResponse: result.priceAdjustmentResponse || null,
})
+
+ // 입찰 조건 로드
+ if (conditionsResult) {
+ console.log('입찰 조건 로드:', conditionsResult)
+ setBiddingConditions(conditionsResult)
+ }
}
// PR 아이템 설정
@@ -158,29 +297,70 @@ export function PartnersBiddingDetail({ biddingId, companyId }: PartnersBiddingD
try {
// 사전견적 데이터를 가져와서 본입찰용으로 변환
const preQuoteData = await getSavedPrItemQuotations(result.biddingCompanyId)
-
- // 사전견적 데이터를 본입찰 포맷으로 변환
- const convertedQuotations = preQuoteData.map(item => ({
- prItemId: item.prItemId,
- bidUnitPrice: item.bidUnitPrice,
- bidAmount: item.bidAmount,
- proposedDeliveryDate: item.proposedDeliveryDate || undefined,
- technicalSpecification: item.technicalSpecification || undefined
- }))
-
- setPrItemQuotations(convertedQuotations)
-
- // 총 금액 계산
- const total = convertedQuotations.reduce((sum, q) => sum + q.bidAmount, 0)
- setTotalQuotationAmount(total)
+
+ if (preQuoteData && Array.isArray(preQuoteData) && preQuoteData.length > 0) {
+ console.log('사전견적 데이터:', preQuoteData)
+
+ // 사전견적 데이터를 본입찰 포맷으로 변환
+ const convertedQuotations = preQuoteData
+ .filter(item => item && typeof item === 'object' && item.prItemId)
+ .map(item => ({
+ prItemId: item.prItemId,
+ bidUnitPrice: item.bidUnitPrice,
+ bidAmount: item.bidAmount,
+ proposedDeliveryDate: item.proposedDeliveryDate || undefined,
+ technicalSpecification: item.technicalSpecification || undefined
+ }))
+
+ console.log('변환된 견적 데이터:', convertedQuotations)
+
+ if (Array.isArray(convertedQuotations) && convertedQuotations.length > 0) {
+ setPrItemQuotations(convertedQuotations)
+
+ // 총 금액 계산
+ const total = convertedQuotations.reduce((sum, q) => {
+ const amount = Number(q.bidAmount) || 0
+ return sum + amount
+ }, 0)
+ setTotalQuotationAmount(total)
+ console.log('계산된 총 금액:', total)
+ }
+ }
// 응찰 확정 시에만 사전견적 금액을 finalQuoteAmount로 설정
- if (totalQuotationAmount > 0 && result.isBiddingParticipated === true) {
+ if (totalQuotationAmount > 0 && result?.isBiddingParticipated === true) {
+ console.log('응찰 확정됨, 사전견적 금액 설정:', totalQuotationAmount)
+ console.log('사전견적 금액을 finalQuoteAmount로 설정:', totalQuotationAmount)
setResponseData(prev => ({
...prev,
finalQuoteAmount: totalQuotationAmount.toString()
}))
}
+
+ // 연동제 데이터 로드 (사전견적에서 답변했으면 로드, 아니면 입찰 조건 확인)
+ if (result.priceAdjustmentResponse !== null) {
+ // 사전견적에서 이미 답변한 경우 - 연동제 폼 로드
+ const savedPriceAdjustmentForm = await getPriceAdjustmentFormByBiddingCompanyId(result.biddingCompanyId)
+ if (savedPriceAdjustmentForm) {
+ setPriceAdjustmentForm({
+ itemName: savedPriceAdjustmentForm.itemName || '',
+ adjustmentReflectionPoint: savedPriceAdjustmentForm.adjustmentReflectionPoint || '',
+ majorApplicableRawMaterial: savedPriceAdjustmentForm.majorApplicableRawMaterial || '',
+ adjustmentFormula: savedPriceAdjustmentForm.adjustmentFormula || '',
+ rawMaterialPriceIndex: savedPriceAdjustmentForm.rawMaterialPriceIndex || '',
+ referenceDate: savedPriceAdjustmentForm.referenceDate || '',
+ comparisonDate: savedPriceAdjustmentForm.comparisonDate || '',
+ adjustmentRatio: savedPriceAdjustmentForm.adjustmentRatio || '',
+ notes: savedPriceAdjustmentForm.notes || '',
+ adjustmentConditions: savedPriceAdjustmentForm.adjustmentConditions || '',
+ majorNonApplicableRawMaterial: savedPriceAdjustmentForm.majorNonApplicableRawMaterial || '',
+ adjustmentPeriod: savedPriceAdjustmentForm.adjustmentPeriod || '',
+ contractorWriter: savedPriceAdjustmentForm.contractorWriter || '',
+ adjustmentDate: savedPriceAdjustmentForm.adjustmentDate || '',
+ nonApplicableReason: savedPriceAdjustmentForm.nonApplicableReason || '',
+ })
+ }
+ }
} catch (error) {
console.error('Failed to load pre-quote data:', error)
}
@@ -229,23 +409,38 @@ export function PartnersBiddingDetail({ biddingId, companyId }: PartnersBiddingD
if (participated && updatedDetail.biddingCompanyId) {
try {
const preQuoteData = await getSavedPrItemQuotations(updatedDetail.biddingCompanyId)
- const convertedQuotations = preQuoteData.map(item => ({
- prItemId: item.prItemId,
- bidUnitPrice: item.bidUnitPrice,
- bidAmount: item.bidAmount,
- proposedDeliveryDate: item.proposedDeliveryDate || undefined,
- technicalSpecification: item.technicalSpecification || undefined
- }))
-
- setPrItemQuotations(convertedQuotations)
- const total = convertedQuotations.reduce((sum, q) => sum + q.bidAmount, 0)
- setTotalQuotationAmount(total)
-
- if (total > 0) {
- setResponseData(prev => ({
- ...prev,
- finalQuoteAmount: total.toString()
- }))
+
+ if (preQuoteData && Array.isArray(preQuoteData) && preQuoteData.length > 0) {
+ console.log('참여확정 후 사전견적 데이터:', preQuoteData)
+
+ const convertedQuotations = preQuoteData
+ .filter(item => item && typeof item === 'object' && item.prItemId)
+ .map(item => ({
+ prItemId: item.prItemId,
+ bidUnitPrice: item.bidUnitPrice,
+ bidAmount: item.bidAmount,
+ proposedDeliveryDate: item.proposedDeliveryDate || undefined,
+ technicalSpecification: item.technicalSpecification || undefined
+ }))
+
+ console.log('참여확정 후 변환된 견적 데이터:', convertedQuotations)
+
+ if (Array.isArray(convertedQuotations) && convertedQuotations.length > 0) {
+ setPrItemQuotations(convertedQuotations)
+ const total = convertedQuotations.reduce((sum, q) => {
+ const amount = Number(q.bidAmount) || 0
+ return sum + amount
+ }, 0)
+ setTotalQuotationAmount(total)
+ console.log('참여확정 후 계산된 총 금액:', total)
+
+ if (total > 0) {
+ setResponseData(prev => ({
+ ...prev,
+ finalQuoteAmount: total.toString()
+ }))
+ }
+ }
}
} catch (error) {
console.error('Failed to load pre-quote data after participation:', error)
@@ -356,6 +551,59 @@ export function PartnersBiddingDetail({ biddingId, companyId }: PartnersBiddingD
}
}
+ // 응찰 취소 핸들러
+ const handleCancelResponse = async () => {
+ if (!biddingDetail || !userId) return
+
+ // 최종제출한 경우 취소 불가
+ if (biddingDetail.isFinalSubmission) {
+ toast({
+ title: '취소 불가',
+ description: '최종 제출된 응찰은 취소할 수 없습니다.',
+ variant: 'destructive',
+ })
+ return
+ }
+
+ if (isBrowser && !window.confirm('응찰을 취소하시겠습니까? 작성한 견적 내용이 모두 삭제됩니다.')) {
+ return
+ }
+
+ setIsCancelling(true)
+ try {
+ const result = await cancelBiddingResponse(biddingDetail.biddingCompanyId, userId)
+
+ if (result.success) {
+ toast({
+ title: '응찰 취소 완료',
+ description: '응찰이 취소되었습니다.',
+ })
+ // 페이지 새로고침
+ if (isBrowser) {
+ window.location.reload()
+ } else {
+ // 서버사이드에서는 라우터로 이동
+ router.push(`/partners/bid/${biddingId}`)
+ }
+ } else {
+ toast({
+ title: '응찰 취소 실패',
+ description: result.error,
+ variant: 'destructive',
+ })
+ }
+ } catch (error) {
+ console.error('Failed to cancel bidding response:', error)
+ toast({
+ title: '오류',
+ description: '응찰 취소에 실패했습니다.',
+ variant: 'destructive',
+ })
+ } finally {
+ setIsCancelling(false)
+ }
+ }
+
const handleSubmitResponse = () => {
if (!biddingDetail) return
// 입찰 마감 상태 체크
@@ -412,6 +660,7 @@ export function PartnersBiddingDetail({ biddingId, companyId }: PartnersBiddingD
finalQuoteAmount: parseFloat(responseData.finalQuoteAmount),
proposedContractDeliveryDate: responseData.proposedContractDeliveryDate,
additionalProposals: responseData.additionalProposals,
+ isFinalSubmission, // 최종제출 여부 추가
prItemQuotations: prItemQuotations.length > 0 ? prItemQuotations.map(q => ({
prItemId: q.prItemId,
bidUnitPrice: q.bidUnitPrice,
@@ -425,8 +674,8 @@ export function PartnersBiddingDetail({ biddingId, companyId }: PartnersBiddingD
if (result.success) {
toast({
- title: '응찰 완료',
- description: '견적이 성공적으로 제출되었습니다.',
+ title: isFinalSubmission ? '응찰 완료' : '임시 저장 완료',
+ description: isFinalSubmission ? '견적이 최종 제출되었습니다.' : '견적이 임시 저장되었습니다.',
})
// 데이터 새로고침
@@ -488,9 +737,6 @@ export function PartnersBiddingDetail({ biddingId, companyId }: PartnersBiddingD
<Badge variant="outline" className="font-mono text-xs">
{biddingDetail.biddingNumber}
</Badge>
- <Badge variant="outline" className="font-mono">
- Rev. {biddingDetail.revision ?? 0}
- </Badge>
<Badge variant={
biddingDetail.status === 'bidding_disposal' ? 'destructive' :
biddingDetail.status === 'vendor_selected' ? 'default' :
@@ -525,20 +771,7 @@ export function PartnersBiddingDetail({ biddingId, companyId }: PartnersBiddingD
</CardHeader>
<CardContent className="space-y-4">
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
- <div>
- <Label className="text-sm font-medium text-muted-foreground">프로젝트</Label>
- <div className="flex items-center gap-2 mt-1">
- <Building2 className="w-4 h-4" />
- <span>{biddingDetail.projectName || '미설정'}</span>
- </div>
- </div>
- <div>
- <Label className="text-sm font-medium text-muted-foreground">품목</Label>
- <div className="flex items-center gap-2 mt-1">
- <Package className="w-4 h-4" />
- <span>{biddingDetail.itemName || '미설정'}</span>
- </div>
- </div>
+
<div>
<Label className="text-sm font-medium text-muted-foreground">계약구분</Label>
<div className="mt-1">{contractTypeLabels[biddingDetail.contractType]}</div>
@@ -552,22 +785,87 @@ export function PartnersBiddingDetail({ biddingId, companyId }: PartnersBiddingD
<div className="mt-1">{biddingDetail.awardCount === 'single' ? '단수' : biddingDetail.awardCount === 'multiple' ? '복수' : '미설정'}</div>
</div>
<div>
- <Label className="text-sm font-medium text-muted-foreground">입찰 담당자</Label>
+ <Label className="text-sm font-medium text-muted-foreground">입찰담당자</Label>
<div className="flex items-center gap-2 mt-1">
<User className="w-4 h-4" />
- <span>{biddingDetail.managerName || '미설정'}</span>
+ <span>{biddingDetail.bidPicName || '미설정'}</span>
</div>
</div>
- </div>
-
- {/* {biddingDetail.budget && (
<div>
- <Label className="text-sm font-medium text-muted-foreground">예산</Label>
+ <Label className="text-sm font-medium text-muted-foreground">조달담당자</Label>
<div className="flex items-center gap-2 mt-1">
- <DollarSign className="w-4 h-4" />
- <span className="font-semibold">{formatCurrency(biddingDetail.budget)}</span>
+ <User className="w-4 h-4" />
+ <span>{biddingDetail.supplyPicName || '미설정'}</span>
+ </div>
+ </div>
+ </div>
+
+ {/* 계약기간 */}
+ {biddingDetail.contractStartDate && biddingDetail.contractEndDate && (
+ <div className="pt-4 border-t">
+ <Label className="text-sm font-medium text-muted-foreground mb-2 block">계약기간</Label>
+ <div className="p-3 bg-muted/50 rounded-lg">
+ <div className="flex items-center gap-2 text-sm">
+ <span className="font-medium">{formatDate(biddingDetail.contractStartDate, 'KR')}</span>
+ <span className="text-muted-foreground">~</span>
+ <span className="font-medium">{formatDate(biddingDetail.contractEndDate, 'KR')}</span>
+ </div>
</div>
</div>
+ )}
+
+
+ {/* 제출 마감일 D-day */}
+ {/* {biddingDetail.submissionEndDate && (
+ <div className="pt-4 border-t">
+ <Label className="text-sm font-medium text-muted-foreground mb-2 block">제출 마감 정보</Label>
+ {(() => {
+ const now = new Date()
+ const deadline = new Date(biddingDetail.submissionEndDate)
+ const isExpired = deadline < now
+ const timeLeft = deadline.getTime() - now.getTime()
+ const daysLeft = Math.floor(timeLeft / (1000 * 60 * 60 * 24))
+ const hoursLeft = Math.floor((timeLeft % (1000 * 60 * 60 * 24)) / (1000 * 60 * 60))
+
+ return (
+ <div className={`p-3 rounded-lg border-2 ${
+ isExpired
+ ? 'border-red-200 bg-red-50'
+ : daysLeft <= 1
+ ? 'border-orange-200 bg-orange-50'
+ : 'border-green-200 bg-green-50'
+ }`}>
+ <div className="flex items-center justify-between">
+ <div className="flex items-center gap-2">
+ <Calendar className="w-5 h-5" />
+ <span className="font-medium">제출 마감일:</span>
+ <span className="text-lg font-semibold">
+ {formatDate(biddingDetail.submissionEndDate, 'KR')}
+ </span>
+ </div>
+ {isExpired ? (
+ <Badge variant="destructive" className="ml-2">
+ 마감됨
+ </Badge>
+ ) : daysLeft <= 1 ? (
+ <Badge variant="secondary" className="ml-2 bg-orange-100 text-orange-800">
+ {daysLeft === 0 ? `${hoursLeft}시간 남음` : `${daysLeft}일 남음`}
+ </Badge>
+ ) : (
+ <Badge variant="secondary" className="ml-2 bg-green-100 text-green-800">
+ {daysLeft}일 남음
+ </Badge>
+ )}
+ </div>
+ {isExpired && (
+ <div className="mt-2 text-sm text-red-600">
+ ⚠️ 제출 마감일이 지났습니다. 입찰 제출이 불가능합니다.
+ </div>
+ )}
+ </div>
+ )
+ })()}
+ </div>
)} */}
{/* 일정 정보 */}
@@ -576,7 +874,7 @@ export function PartnersBiddingDetail({ biddingId, companyId }: PartnersBiddingD
<div className="grid grid-cols-1 md:grid-cols-2 gap-2 text-sm">
{biddingDetail.submissionStartDate && biddingDetail.submissionEndDate && (
<div>
- <span className="font-medium">제출기간:</span> {formatDate(biddingDetail.submissionStartDate, 'KR')} ~ {formatDate(biddingDetail.submissionEndDate, 'KR')}
+ <span className="font-medium">응찰기간:</span> {formatDate(biddingDetail.submissionStartDate, 'KR')} ~ {formatDate(biddingDetail.submissionEndDate, 'KR')}
</div>
)}
{biddingDetail.evaluationDate && (
@@ -589,6 +887,130 @@ export function PartnersBiddingDetail({ biddingId, companyId }: PartnersBiddingD
</CardContent>
</Card>
+ {/* 입찰공고 토글 섹션 */}
+ {biddingNotice && (
+ <Card>
+ <Collapsible open={isNoticeOpen} onOpenChange={setIsNoticeOpen}>
+ <CollapsibleTrigger asChild>
+ <CardHeader className="cursor-pointer hover:bg-muted/50 transition-colors">
+ <CardTitle className="flex items-center justify-between">
+ <div className="flex items-center gap-2">
+ <FileText className="w-5 h-5" />
+ 입찰공고 내용
+ </div>
+ <ChevronDown className={`w-5 h-5 transition-transform ${isNoticeOpen ? 'rotate-180' : ''}`} />
+ </CardTitle>
+ </CardHeader>
+ </CollapsibleTrigger>
+ <CollapsibleContent>
+ <CardContent className="pt-0">
+ <div className="p-4 bg-muted/50 rounded-lg">
+ {biddingNotice.title && (
+ <h3 className="font-semibold text-lg mb-3">{biddingNotice.title}</h3>
+ )}
+ {biddingNotice.content ? (
+ <div
+ className="prose prose-sm max-w-none"
+ dangerouslySetInnerHTML={{
+ __html: biddingNotice.content
+ }}
+ />
+ ) : (
+ <p className="text-muted-foreground">입찰공고 내용이 없습니다.</p>
+ )}
+ </div>
+ </CardContent>
+ </CollapsibleContent>
+ </Collapsible>
+ </Card>
+ )}
+
+ {/* 현재 설정된 조건 섹션 */}
+ {biddingConditions && (
+ <Card>
+ <CardHeader>
+ <CardTitle>현재 설정된 입찰 조건</CardTitle>
+ </CardHeader>
+ <CardContent>
+ <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4 text-sm">
+ <div>
+ <Label className="text-muted-foreground">지급조건</Label>
+ <div className="mt-1 p-3 bg-muted rounded-md">
+ <p className="font-medium">{biddingConditions.paymentTerms || "미설정"}</p>
+ </div>
+ </div>
+
+ <div>
+ <Label className="text-muted-foreground">부가세구분</Label>
+ <div className="mt-1 p-3 bg-muted rounded-md">
+ <p className="font-medium">
+ {biddingConditions.taxConditions
+ ? getTaxConditionName(biddingConditions.taxConditions)
+ : "미설정"
+ }
+ </p>
+ </div>
+ </div>
+
+ <div>
+ <Label className="text-muted-foreground">인도조건</Label>
+ <div className="mt-1 p-3 bg-muted rounded-md">
+ <p className="font-medium">{biddingConditions.incoterms || "미설정"}</p>
+ </div>
+ </div>
+ <div>
+ <Label className="text-muted-foreground">인도조건2</Label>
+ <div className="mt-1 p-3 bg-muted rounded-md">
+ <p className="font-medium">{biddingConditions.incotermsOption || "미설정"}</p>
+ </div>
+ </div>
+
+ <div>
+ <Label className="text-muted-foreground">계약 납기일</Label>
+ <div className="mt-1 p-3 bg-muted rounded-md">
+ <p className="font-medium">
+ {biddingConditions.contractDeliveryDate
+ ? formatDate(biddingConditions.contractDeliveryDate, 'KR')
+ : "미설정"
+ }
+ </p>
+ </div>
+ </div>
+
+ <div>
+ <Label className="text-muted-foreground">선적지</Label>
+ <div className="mt-1 p-3 bg-muted rounded-md">
+ <p className="font-medium">{biddingConditions.shippingPort || "미설정"}</p>
+ </div>
+ </div>
+
+ <div>
+ <Label className="text-muted-foreground">하역지</Label>
+ <div className="mt-1 p-3 bg-muted rounded-md">
+ <p className="font-medium">{biddingConditions.destinationPort || "미설정"}</p>
+ </div>
+ </div>
+
+ <div>
+ <Label className="text-muted-foreground">연동제 적용</Label>
+ <div className="mt-1 p-3 bg-muted rounded-md">
+ <p className="font-medium">{biddingConditions.isPriceAdjustmentApplicable ? "적용 가능" : "적용 불가"}</p>
+ </div>
+ </div>
+
+
+ <div >
+ <Label className="text-muted-foreground">스페어파트 옵션</Label>
+ <div className="mt-1 p-3 bg-muted rounded-md">
+ <p className="font-medium">{biddingConditions.sparePartOptions}</p>
+ </div>
+ </div>
+ </div>
+ </CardContent>
+ </Card>
+ )}
+
+
{/* 참여 상태에 따른 섹션 표시 */}
{biddingDetail.isBiddingParticipated === false ? (
@@ -687,25 +1109,315 @@ export function PartnersBiddingDetail({ biddingId, companyId }: PartnersBiddingD
readOnly={false}
/>
)}
+
+ {/* 연동제 적용 여부 - SHI가 연동제를 요구하고, 사전견적에서 답변하지 않은 경우만 표시 */}
+ {biddingConditions?.isPriceAdjustmentApplicable && biddingDetail.priceAdjustmentResponse === null && (
+ <>
+ <div className="space-y-3 p-4 border rounded-lg bg-muted/30">
+ <Label className="font-semibold text-base">연동제 적용 여부 *</Label>
+ <RadioGroup
+ value={responseData.priceAdjustmentResponse === null ? 'none' : responseData.priceAdjustmentResponse ? 'apply' : 'not-apply'}
+ onValueChange={(value) => {
+ const newValue = value === 'apply' ? true : value === 'not-apply' ? false : null
+ setResponseData({...responseData, priceAdjustmentResponse: newValue})
+ }}
+ >
+ <div className="flex items-center space-x-2">
+ <RadioGroupItem value="apply" id="price-adjustment-apply" />
+ <Label htmlFor="price-adjustment-apply" className="font-normal cursor-pointer">
+ 연동제 적용
+ </Label>
+ </div>
+ <div className="flex items-center space-x-2">
+ <RadioGroupItem value="not-apply" id="price-adjustment-not-apply" />
+ <Label htmlFor="price-adjustment-not-apply" className="font-normal cursor-pointer">
+ 연동제 미적용
+ </Label>
+ </div>
+ </RadioGroup>
+ </div>
+
+ {/* 연동제 상세 정보 */}
+ {responseData.priceAdjustmentResponse !== null && (
+ <Card className="mt-6">
+ <CardHeader>
+ <CardTitle className="text-lg">하도급대금등 연동표</CardTitle>
+ </CardHeader>
+ <CardContent className="space-y-4">
+ {/* 공통 필드 - 품목등의 명칭 */}
+ <div className="space-y-2">
+ <Label htmlFor="itemName">품목등의 명칭 *</Label>
+ <Input
+ id="itemName"
+ value={priceAdjustmentForm.itemName}
+ onChange={(e) => setPriceAdjustmentForm({...priceAdjustmentForm, itemName: e.target.value})}
+ placeholder="품목명을 입력하세요"
+ required
+ />
+ </div>
+
+ {/* 연동제 적용 시 - 모든 필드 표시 */}
+ {responseData.priceAdjustmentResponse === true && (
+ <>
+ <div className="grid grid-cols-1 md:grid-cols-2 gap-4">
+ <div className="space-y-2">
+ <Label htmlFor="adjustmentReflectionPoint">조정대금 반영시점 *</Label>
+ <Input
+ id="adjustmentReflectionPoint"
+ value={priceAdjustmentForm.adjustmentReflectionPoint}
+ onChange={(e) => setPriceAdjustmentForm({...priceAdjustmentForm, adjustmentReflectionPoint: e.target.value})}
+ placeholder="반영시점을 입력하세요"
+ required
+ />
+ </div>
+
+ <div className="space-y-2">
+ <Label htmlFor="adjustmentRatio">연동 비율 (%) *</Label>
+ <Input
+ id="adjustmentRatio"
+ type="number"
+ step="0.01"
+ value={priceAdjustmentForm.adjustmentRatio}
+ onChange={(e) => setPriceAdjustmentForm({...priceAdjustmentForm, adjustmentRatio: e.target.value})}
+ placeholder="비율을 입력하세요"
+ required
+ />
+ </div>
+
+ <div className="space-y-2">
+ <Label htmlFor="adjustmentPeriod">조정주기 *</Label>
+ <Input
+ id="adjustmentPeriod"
+ value={priceAdjustmentForm.adjustmentPeriod}
+ onChange={(e) => setPriceAdjustmentForm({...priceAdjustmentForm, adjustmentPeriod: e.target.value})}
+ placeholder="조정주기를 입력하세요"
+ required
+ />
+ </div>
+
+ <div className="space-y-2">
+ <Label htmlFor="referenceDate">기준시점 *</Label>
+ <Input
+ id="referenceDate"
+ type="date"
+ value={priceAdjustmentForm.referenceDate}
+ onChange={(e) => setPriceAdjustmentForm({...priceAdjustmentForm, referenceDate: e.target.value})}
+ required
+ />
+ </div>
+
+ <div className="space-y-2">
+ <Label htmlFor="comparisonDate">비교시점 *</Label>
+ <Input
+ id="comparisonDate"
+ type="date"
+ value={priceAdjustmentForm.comparisonDate}
+ onChange={(e) => setPriceAdjustmentForm({...priceAdjustmentForm, comparisonDate: e.target.value})}
+ required
+ />
+ </div>
+
+ <div className="space-y-2">
+ <Label htmlFor="contractorWriter">수탁기업(협력사) 작성자 *</Label>
+ <Input
+ id="contractorWriter"
+ value={priceAdjustmentForm.contractorWriter}
+ onChange={(e) => setPriceAdjustmentForm({...priceAdjustmentForm, contractorWriter: e.target.value})}
+ placeholder="작성자명을 입력하세요"
+ required
+ />
+ </div>
+
+ <div className="space-y-2">
+ <Label htmlFor="adjustmentDate">조정일 *</Label>
+ <Input
+ id="adjustmentDate"
+ type="date"
+ value={priceAdjustmentForm.adjustmentDate}
+ onChange={(e) => setPriceAdjustmentForm({...priceAdjustmentForm, adjustmentDate: e.target.value})}
+ required
+ />
+ </div>
+ </div>
+
+ <div className="space-y-2">
+ <Label htmlFor="majorApplicableRawMaterial">연동대상 주요 원재료 *</Label>
+ <Textarea
+ id="majorApplicableRawMaterial"
+ value={priceAdjustmentForm.majorApplicableRawMaterial}
+ onChange={(e) => setPriceAdjustmentForm({...priceAdjustmentForm, majorApplicableRawMaterial: e.target.value})}
+ placeholder="연동 대상 원재료를 입력하세요"
+ rows={3}
+ required
+ />
+ </div>
+
+ <div className="space-y-2">
+ <Label htmlFor="adjustmentFormula">하도급대금등 연동 산식 *</Label>
+ <Textarea
+ id="adjustmentFormula"
+ value={priceAdjustmentForm.adjustmentFormula}
+ onChange={(e) => setPriceAdjustmentForm({...priceAdjustmentForm, adjustmentFormula: e.target.value})}
+ placeholder="연동 산식을 입력하세요"
+ rows={3}
+ required
+ />
+ </div>
+
+ <div className="space-y-2">
+ <Label htmlFor="rawMaterialPriceIndex">원재료 가격 기준지표 *</Label>
+ <Textarea
+ id="rawMaterialPriceIndex"
+ value={priceAdjustmentForm.rawMaterialPriceIndex}
+ onChange={(e) => setPriceAdjustmentForm({...priceAdjustmentForm, rawMaterialPriceIndex: e.target.value})}
+ placeholder="가격 기준지표를 입력하세요"
+ rows={2}
+ required
+ />
+ </div>
+
+ <div className="space-y-2">
+ <Label htmlFor="adjustmentConditions">조정요건 *</Label>
+ <Textarea
+ id="adjustmentConditions"
+ value={priceAdjustmentForm.adjustmentConditions}
+ onChange={(e) => setPriceAdjustmentForm({...priceAdjustmentForm, adjustmentConditions: e.target.value})}
+ placeholder="조정요건을 입력하세요"
+ rows={2}
+ required
+ />
+ </div>
+
+ <div className="space-y-2">
+ <Label htmlFor="priceAdjustmentNotes">기타 사항</Label>
+ <Textarea
+ id="priceAdjustmentNotes"
+ value={priceAdjustmentForm.notes}
+ onChange={(e) => setPriceAdjustmentForm({...priceAdjustmentForm, notes: e.target.value})}
+ placeholder="기타 사항을 입력하세요"
+ rows={2}
+ />
+ </div>
+ </>
+ )}
+
+ {/* 연동제 미적용 시 - 제한된 필드만 표시 */}
+ {responseData.priceAdjustmentResponse === false && (
+ <>
+ <div className="space-y-2">
+ <Label htmlFor="majorNonApplicableRawMaterial">연동제 미적용 주요 원재료 *</Label>
+ <Textarea
+ id="majorNonApplicableRawMaterial"
+ value={priceAdjustmentForm.majorNonApplicableRawMaterial}
+ onChange={(e) => setPriceAdjustmentForm({...priceAdjustmentForm, majorNonApplicableRawMaterial: e.target.value})}
+ placeholder="연동 미적용 원재료를 입력하세요"
+ rows={2}
+ required
+ />
+ </div>
+
+ <div className="space-y-2">
+ <Label htmlFor="contractorWriter">수탁기업(협력사) 작성자 *</Label>
+ <Input
+ id="contractorWriter"
+ value={priceAdjustmentForm.contractorWriter}
+ onChange={(e) => setPriceAdjustmentForm({...priceAdjustmentForm, contractorWriter: e.target.value})}
+ placeholder="작성자명을 입력하세요"
+ required
+ />
+ </div>
+
+ <div className="space-y-2">
+ <Label htmlFor="nonApplicableReason">연동제 미희망 사유 *</Label>
+ <Textarea
+ id="nonApplicableReason"
+ value={priceAdjustmentForm.nonApplicableReason}
+ onChange={(e) => setPriceAdjustmentForm({...priceAdjustmentForm, nonApplicableReason: e.target.value})}
+ placeholder="미희망 사유를 입력하세요"
+ rows={3}
+ required
+ />
+ </div>
+ </>
+ )}
+ </CardContent>
+ </Card>
+ )}
+ </>
+ )}
+
+ {/* 사전견적에서 이미 답변한 경우 - 읽기 전용으로 표시 */}
+ {biddingDetail.priceAdjustmentResponse !== null && (
+ <Card>
+ <CardHeader>
+ <CardTitle className="text-lg">연동제 적용 정보 (사전견적 제출 완료)</CardTitle>
+ </CardHeader>
+ <CardContent>
+ <div className="p-4 bg-muted/30 rounded-lg">
+ <div className="flex items-center gap-2 mb-2">
+ <CheckCircle className="w-5 h-5 text-green-600" />
+ <span className="font-semibold">
+ {biddingDetail.priceAdjustmentResponse ? '연동제 적용' : '연동제 미적용'}
+ </span>
+ </div>
+ <p className="text-sm text-muted-foreground">
+ 사전견적에서 이미 연동제 관련 정보를 제출하였습니다. 본입찰에서는 별도의 연동제 정보 입력이 필요하지 않습니다.
+ </p>
+ </div>
+ </CardContent>
+ </Card>
+ )}
+
+ {/* 최종제출 체크박스 */}
+ {!biddingDetail.isFinalSubmission && (
+ <div className="flex items-center space-x-2 p-4 border rounded-lg bg-muted/30">
+ <input
+ type="checkbox"
+ id="finalSubmission"
+ checked={isFinalSubmission}
+ onChange={(e) => setIsFinalSubmission(e.target.checked)}
+ disabled={isSubmitting || isSavingDraft}
+ className="h-4 w-4 rounded border-gray-300"
+ />
+ <label htmlFor="finalSubmission" className="text-sm font-medium cursor-pointer">
+ 최종 제출 (체크 시 제출 후 수정 및 취소 불가)
+ </label>
+ </div>
+ )}
+
{/* 응찰 제출 버튼 - 참여 확정 상태일 때만 표시 */}
- <div className="flex justify-end pt-4 gap-2">
- <Button
- variant="outline"
- onClick={handleSaveDraft}
- disabled={isSavingDraft || isSubmitting}
- className="min-w-[100px]"
- >
- <Save className="w-4 h-4 mr-2" />
- {isSavingDraft ? '저장 중...' : '임시 저장'}
- </Button>
- <Button
- onClick={handleSubmitResponse}
- disabled={isSubmitting || isSavingDraft || !!biddingDetail.responseSubmittedAt}
- className="min-w-[100px]"
- >
- <Send className="w-4 h-4 mr-2" />
- {isSubmitting ? '제출 중...' : biddingDetail.responseSubmittedAt ? '응찰 완료' : '응찰 제출'}
- </Button>
+ <div className="flex justify-between pt-4 gap-2">
+ {/* 응찰 취소 버튼 (최종제출 아닌 경우만) */}
+ {biddingDetail.finalQuoteSubmittedAt && !biddingDetail.isFinalSubmission && (
+ <Button
+ variant="destructive"
+ onClick={handleCancelResponse}
+ disabled={isCancelling || isSubmitting}
+ className="min-w-[100px]"
+ >
+ <Trash2 className="w-4 h-4 mr-2" />
+ {isCancelling ? '취소 중...' : '응찰 취소'}
+ </Button>
+ )}
+ <div className="flex gap-2 ml-auto">
+ <Button
+ variant="outline"
+ onClick={handleSaveDraft}
+ disabled={isSavingDraft || isSubmitting || biddingDetail.isFinalSubmission}
+ className="min-w-[100px]"
+ >
+ <Save className="w-4 h-4 mr-2" />
+ {isSavingDraft ? '저장 중...' : '임시 저장'}
+ </Button>
+ <Button
+ onClick={handleSubmitResponse}
+ disabled={isSubmitting || isSavingDraft || biddingDetail.isFinalSubmission}
+ className="min-w-[100px]"
+ >
+ <Send className="w-4 h-4 mr-2" />
+ {isSubmitting ? '제출 중...' : biddingDetail.isFinalSubmission ? '최종 제출 완료' : '응찰 제출'}
+ </Button>
+ </div>
</div>
</CardContent>
</Card>
diff --git a/lib/bidding/vendor/partners-bidding-list-columns.tsx b/lib/bidding/vendor/partners-bidding-list-columns.tsx
index 7fb62122..5870067a 100644
--- a/lib/bidding/vendor/partners-bidding-list-columns.tsx
+++ b/lib/bidding/vendor/partners-bidding-list-columns.tsx
@@ -17,7 +17,6 @@ import {
MoreHorizontal,
Calendar,
User,
- Calculator,
Paperclip,
AlertTriangle
} from 'lucide-react'
@@ -25,6 +24,7 @@ import { formatDate } from '@/lib/utils'
import { biddingStatusLabels, contractTypeLabels } from '@/db/schema'
import { PartnersBiddingListItem } from '../detail/service'
import { Checkbox } from '@/components/ui/checkbox'
+import { toast } from 'sonner'
const columnHelper = createColumnHelper<PartnersBiddingListItem>()
@@ -62,11 +62,15 @@ export function getPartnersBiddingListColumns({ setRowAction }: PartnersBiddingL
header: '입찰 No.',
cell: ({ row }) => {
const biddingNumber = row.original.biddingNumber
+ const originalBiddingNumber = row.original.originalBiddingNumber
const revision = row.original.revision
return (
<div className="font-mono text-sm">
<div>{biddingNumber}</div>
- <div className="text-muted-foreground">Rev. {revision ?? 0}</div>
+ <div className="text-muted-foreground text-xs">Rev. {revision ?? 0}</div>
+ {originalBiddingNumber && (
+ <div className="text-xs text-muted-foreground">원: {originalBiddingNumber}</div>
+ )}
</div>
)
},
@@ -148,27 +152,36 @@ export function getPartnersBiddingListColumns({ setRowAction }: PartnersBiddingL
id: 'actions',
header: '액션',
cell: ({ row }) => {
- const handleView = () => {
- if (setRowAction) {
- setRowAction({
- type: 'view',
- row: { original: row.original }
+ // 사양설명회 참석여부 체크 함수
+ const checkSpecificationMeeting = () => {
+ const hasSpecMeeting = row.original.hasSpecificationMeeting
+ const isAttending = row.original.isAttendingMeeting
+
+ // 사양설명회가 있고, 참석여부가 아직 설정되지 않은 경우
+ if (hasSpecMeeting && isAttending === null) {
+ toast.warning('사양설명회 참석여부 필요', {
+ description: '사전견적 또는 입찰을 진행하기 전에 사양설명회 참석여부를 먼저 설정해주세요.',
+ duration: 5000,
})
+ return false
}
+ return true
}
- const handlePreQuote = () => {
+ const handleView = () => {
+ // 사양설명회 체크
+ if (!checkSpecificationMeeting()) {
+ return
+ }
+
if (setRowAction) {
setRowAction({
- type: 'pre-quote',
+ type: 'view',
row: { original: row.original }
})
}
}
- const biddingStatus = row.original.status
- const isClosed = biddingStatus === 'bidding_closed' || biddingStatus === 'vendor_selected' || biddingStatus === 'bidding_disposal'
-
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
@@ -185,12 +198,6 @@ export function getPartnersBiddingListColumns({ setRowAction }: PartnersBiddingL
<FileText className="mr-2 h-4 w-4" />
입찰 상세보기
</DropdownMenuItem>
- {!isClosed && (
- <DropdownMenuItem onClick={handlePreQuote}>
- <Calculator className="mr-2 h-4 w-4" />
- 사전견적하기
- </DropdownMenuItem>
- )}
</DropdownMenuContent>
</DropdownMenu>
)
@@ -327,61 +334,50 @@ export function getPartnersBiddingListColumns({ setRowAction }: PartnersBiddingL
const endDate = row.original.contractEndDate
if (!startDate || !endDate) {
- return <div className="max-w-24 truncate">-</div>
+ return <div className="text-muted-foreground text-center">-</div>
}
return (
- <div className="max-w-24 truncate" title={`${formatDate(startDate, 'KR')} ~ ${formatDate(endDate, 'KR')}`}>
- {formatDate(startDate, 'KR')} ~ {formatDate(endDate, 'KR')}
+ <div className="text-sm">
+ <div>{formatDate(startDate, 'KR')}</div>
+ <div className="text-muted-foreground">~</div>
+ <div>{formatDate(endDate, 'KR')}</div>
</div>
)
},
}),
- // 참여회신 마감일
- columnHelper.accessor('responseDeadline', {
- header: '참여회신 마감일',
- cell: ({ row }) => {
- const deadline = row.original.responseDeadline
- if (!deadline) {
- return <div className="text-muted-foreground">-</div>
- }
- return <div className="text-sm">{formatDate(deadline, 'KR')}</div>
- },
- }),
-
- // 입찰제출일
- columnHelper.accessor('submissionDate', {
- header: '입찰제출일',
+ // 입찰담당자
+ columnHelper.display({
+ id: 'bidPicName',
+ header: '입찰담당자',
cell: ({ row }) => {
- const date = row.original.submissionDate
- if (!date) {
- return <div className="text-muted-foreground">-</div>
+ const name = row.original.bidPicName
+ if (!name) {
+ return <div className="text-muted-foreground text-center">-</div>
}
- return <div className="text-sm">{formatDate(date, 'KR')}</div>
+ return (
+ <div className="flex items-center gap-1">
+ <User className="h-4 w-4" />
+ <div className="text-sm">{name}</div>
+ </div>
+ )
},
}),
- // 입찰담당자
- columnHelper.accessor('managerName', {
- header: '입찰담당자',
+ // 조달담당자
+ columnHelper.display({
+ id: 'supplyPicName',
+ header: '조달담당자',
cell: ({ row }) => {
- const name = row.original.managerName
- const email = row.original.managerEmail
+ const name = row.original.supplyPicName
if (!name) {
- return <div className="text-muted-foreground">-</div>
+ return <div className="text-muted-foreground text-center">-</div>
}
return (
<div className="flex items-center gap-1">
<User className="h-4 w-4" />
- <div>
- <div className="text-sm">{name}</div>
- {email && (
- <div className="text-xs text-muted-foreground truncate max-w-32" title={email}>
- {email}
- </div>
- )}
- </div>
+ <div className="text-sm">{name}</div>
</div>
)
},
diff --git a/lib/bidding/vendor/partners-bidding-pre-quote.tsx b/lib/bidding/vendor/partners-bidding-pre-quote.tsx
deleted file mode 100644
index 8a157c5f..00000000
--- a/lib/bidding/vendor/partners-bidding-pre-quote.tsx
+++ /dev/null
@@ -1,1413 +0,0 @@
-'use client'
-
-import * as React from 'react'
-import { useRouter } from 'next/navigation'
-import { Button } from '@/components/ui/button'
-import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
-import { Badge } from '@/components/ui/badge'
-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 {
- Select,
- SelectContent,
- SelectItem,
- SelectTrigger,
- SelectValue,
-} from '@/components/ui/select'
-import {
- ArrowLeft,
- Calendar,
- Building2,
- Package,
- User,
- FileText,
- Users,
- Send,
- CheckCircle,
- XCircle,
- Save
-} from 'lucide-react'
-
-import { formatDate } from '@/lib/utils'
-import {
- getBiddingCompaniesForPartners,
- submitPreQuoteResponse,
- getPrItemsForBidding,
- getSavedPrItemQuotations,
- savePreQuoteDraft,
- setPreQuoteParticipation
-} from '../pre-quote/service'
-import { getBiddingConditions } from '../service'
-import { getPriceAdjustmentFormByBiddingCompanyId } from '../detail/service'
-import { getIncotermsForSelection, getPaymentTermsForSelection, getPlaceOfShippingForSelection, getPlaceOfDestinationForSelection } from '@/lib/procurement-select/service'
-import { TAX_CONDITIONS, getTaxConditionName } from '@/lib/tax-conditions/types'
-import { PrItemsPricingTable } from './components/pr-items-pricing-table'
-import { SimpleFileUpload } from './components/simple-file-upload'
-import {
- biddingStatusLabels,
-} from '@/db/schema'
-import { useToast } from '@/hooks/use-toast'
-import { useTransition } from 'react'
-import { useSession } from 'next-auth/react'
-
-interface PartnersBiddingPreQuoteProps {
- biddingId: number
- companyId: number
-}
-
-interface BiddingDetail {
- id: number
- biddingNumber: string
- revision: number | null
- projectName: string | null
- itemName: string | null
- title: string
- description: string | null
- content: string | null
- contractType: string
- biddingType: string
- awardCount: string
- contractStartDate: Date | null
- contractEndDate: Date | null
- preQuoteDate: string | null
- biddingRegistrationDate: string | null
- submissionStartDate: string | null
- submissionEndDate: string | null
- evaluationDate: string | null
- currency: string
- budget: number | null
- targetPrice: number | null
- status: string
- managerName: string | null
- managerEmail: string | null
- managerPhone: string | null
- biddingCompanyId: number | null
- biddingId: number // bidding의 ID 추가
- invitationStatus: string | null
- preQuoteAmount: string | null
- preQuoteSubmittedAt: string | null
- preQuoteDeadline: string | null
- isPreQuoteSelected: boolean | null
- isAttendingMeeting: boolean | null
- // companyConditionResponses에서 가져온 조건들 (제시된 조건과 응답 모두)
- paymentTermsResponse: string | null
- taxConditionsResponse: string | null
- incotermsResponse: string | null
- proposedContractDeliveryDate: string | null
- proposedShippingPort: string | null
- proposedDestinationPort: string | null
- priceAdjustmentResponse: boolean | null
- sparePartResponse: string | null
- isInitialResponse: boolean | null
- additionalProposals: string | null
-}
-
-export function PartnersBiddingPreQuote({ biddingId, companyId }: PartnersBiddingPreQuoteProps) {
- const router = useRouter()
- const { toast } = useToast()
- const [isPending, startTransition] = useTransition()
- const session = useSession()
- const [biddingDetail, setBiddingDetail] = React.useState<BiddingDetail | null>(null)
- const [isLoading, setIsLoading] = React.useState(true)
- const [biddingConditions, setBiddingConditions] = React.useState<any | null>(null)
-
- // Procurement 데이터 상태들
- const [paymentTermsOptions, setPaymentTermsOptions] = React.useState<Array<{code: string, description: string}>>([])
- const [incotermsOptions, setIncotermsOptions] = React.useState<Array<{code: string, description: string}>>([])
- const [shippingPlaces, setShippingPlaces] = React.useState<Array<{code: string, description: string}>>([])
- const [destinationPlaces, setDestinationPlaces] = React.useState<Array<{code: string, description: string}>>([])
-
- // 품목별 견적 관련 상태
- const [prItems, setPrItems] = React.useState<any[]>([])
- const [prItemQuotations, setPrItemQuotations] = React.useState<any[]>([])
- const [totalAmount, setTotalAmount] = React.useState(0)
- const [isSaving, setIsSaving] = React.useState(false)
-
- // 사전견적 폼 상태
- const [responseData, setResponseData] = React.useState({
- preQuoteAmount: '',
- paymentTermsResponse: '',
- taxConditionsResponse: '',
- incotermsResponse: '',
- proposedContractDeliveryDate: '',
- proposedShippingPort: '',
- proposedDestinationPort: '',
- priceAdjustmentResponse: false,
- isInitialResponse: false,
- sparePartResponse: '',
- additionalProposals: '',
- isAttendingMeeting: false,
- })
-
- // 사전견적 참여의사 상태
- const [participationDecision, setParticipationDecision] = React.useState<boolean | null>(null)
-
- // 연동제 폼 상태
- const [priceAdjustmentForm, setPriceAdjustmentForm] = React.useState({
- itemName: '',
- adjustmentReflectionPoint: '',
- majorApplicableRawMaterial: '',
- adjustmentFormula: '',
- rawMaterialPriceIndex: '',
- referenceDate: '',
- comparisonDate: '',
- adjustmentRatio: '',
- notes: '',
- adjustmentConditions: '',
- majorNonApplicableRawMaterial: '',
- adjustmentPeriod: '',
- contractorWriter: '',
- adjustmentDate: '',
- nonApplicableReason: '',
- })
- const userId = session.data?.user?.id || ''
-
- // Procurement 데이터 로드 함수들
- const loadPaymentTerms = React.useCallback(async () => {
- try {
- const data = await getPaymentTermsForSelection();
- setPaymentTermsOptions(data);
- } catch (error) {
- console.error("Failed to load payment terms:", error);
- }
- }, []);
-
- const loadIncoterms = React.useCallback(async () => {
- try {
- const data = await getIncotermsForSelection();
- setIncotermsOptions(data);
- } catch (error) {
- console.error("Failed to load incoterms:", error);
- }
- }, []);
-
- const loadShippingPlaces = React.useCallback(async () => {
- try {
- const data = await getPlaceOfShippingForSelection();
- setShippingPlaces(data);
- } catch (error) {
- console.error("Failed to load shipping places:", error);
- }
- }, []);
-
- const loadDestinationPlaces = React.useCallback(async () => {
- try {
- const data = await getPlaceOfDestinationForSelection();
- setDestinationPlaces(data);
- } catch (error) {
- console.error("Failed to load destination places:", error);
- }
- }, []);
-
- // 데이터 로드
- React.useEffect(() => {
- const loadData = async () => {
- try {
- setIsLoading(true)
-
- // 모든 필요한 데이터를 병렬로 로드
- const [result, conditions, prItemsData] = await Promise.all([
- getBiddingCompaniesForPartners(biddingId, companyId),
- getBiddingConditions(biddingId),
- getPrItemsForBidding(biddingId)
- ])
-
- if (result) {
- setBiddingDetail(result as BiddingDetail)
-
- // 저장된 품목별 견적 정보가 있으면 로드
- if (result.biddingCompanyId) {
- const savedQuotations = await getSavedPrItemQuotations(result.biddingCompanyId)
- setPrItemQuotations(savedQuotations)
-
- // 총 금액 계산
- const calculatedTotal = savedQuotations.reduce((sum: number, item: any) => sum + item.bidAmount, 0)
- setTotalAmount(calculatedTotal)
-
- // 저장된 연동제 정보가 있으면 로드
- if (result.priceAdjustmentResponse) {
- const savedPriceAdjustmentForm = await getPriceAdjustmentFormByBiddingCompanyId(result.biddingCompanyId)
- if (savedPriceAdjustmentForm) {
- setPriceAdjustmentForm({
- itemName: savedPriceAdjustmentForm.itemName || '',
- adjustmentReflectionPoint: savedPriceAdjustmentForm.adjustmentReflectionPoint || '',
- majorApplicableRawMaterial: savedPriceAdjustmentForm.majorApplicableRawMaterial || '',
- adjustmentFormula: savedPriceAdjustmentForm.adjustmentFormula || '',
- rawMaterialPriceIndex: savedPriceAdjustmentForm.rawMaterialPriceIndex || '',
- referenceDate: savedPriceAdjustmentForm.referenceDate ? new Date(savedPriceAdjustmentForm.referenceDate).toISOString().split('T')[0] : '',
- comparisonDate: savedPriceAdjustmentForm.comparisonDate ? new Date(savedPriceAdjustmentForm.comparisonDate).toISOString().split('T')[0] : '',
- adjustmentRatio: savedPriceAdjustmentForm.adjustmentRatio?.toString() || '',
- notes: savedPriceAdjustmentForm.notes || '',
- adjustmentConditions: savedPriceAdjustmentForm.adjustmentConditions || '',
- majorNonApplicableRawMaterial: savedPriceAdjustmentForm.majorNonApplicableRawMaterial || '',
- adjustmentPeriod: savedPriceAdjustmentForm.adjustmentPeriod || '',
- contractorWriter: savedPriceAdjustmentForm.contractorWriter || '',
- adjustmentDate: savedPriceAdjustmentForm.adjustmentDate ? new Date(savedPriceAdjustmentForm.adjustmentDate).toISOString().split('T')[0] : '',
- nonApplicableReason: savedPriceAdjustmentForm.nonApplicableReason || '',
- })
- }
- }
- }
-
- // 기존 응답 데이터로 폼 초기화
- setResponseData({
- preQuoteAmount: result.preQuoteAmount?.toString() || '',
- paymentTermsResponse: result.paymentTermsResponse || '',
- taxConditionsResponse: result.taxConditionsResponse || '',
- incotermsResponse: result.incotermsResponse || '',
- proposedContractDeliveryDate: result.proposedContractDeliveryDate || '',
- proposedShippingPort: result.proposedShippingPort || '',
- proposedDestinationPort: result.proposedDestinationPort || '',
- priceAdjustmentResponse: result.priceAdjustmentResponse || false,
- isInitialResponse: result.isInitialResponse || false,
- sparePartResponse: result.sparePartResponse || '',
- additionalProposals: result.additionalProposals || '',
- isAttendingMeeting: result.isAttendingMeeting || false,
- })
-
- // 사전견적 참여의사 초기화
- setParticipationDecision(result.isPreQuoteParticipated)
- }
-
- if (conditions) {
- // BiddingConditionsEdit와 같은 방식으로 raw 데이터 사용
- setBiddingConditions(conditions)
- }
-
- if (prItemsData) {
- setPrItems(prItemsData)
- }
-
- // Procurement 데이터 로드
- await Promise.all([
- loadPaymentTerms(),
- loadIncoterms(),
- loadShippingPlaces(),
- loadDestinationPlaces()
- ])
- } catch (error) {
- console.error('Failed to load bidding company:', error)
- toast({
- title: '오류',
- description: '입찰 정보를 불러오는데 실패했습니다.',
- variant: 'destructive',
- })
- } finally {
- setIsLoading(false)
- }
- }
-
- loadData()
- }, [biddingId, companyId, toast, loadPaymentTerms, loadIncoterms, loadShippingPlaces, loadDestinationPlaces])
-
- // 임시저장 기능
- const handleTempSave = () => {
- if (!biddingDetail || !biddingDetail.biddingCompanyId) {
- toast({
- title: '임시저장 실패',
- description: '입찰 정보가 올바르지 않습니다.',
- variant: 'destructive',
- })
- return
- }
- // 입찰 마감 상태 체크
- const biddingStatus = biddingDetail.status
- const isClosed = biddingStatus === 'bidding_closed' || biddingStatus === 'vendor_selected' || biddingStatus === 'bidding_disposal'
-
- if (isClosed) {
- toast({
- title: "접근 제한",
- description: "입찰이 마감되어 더 이상 사전견적을 제출할 수 없습니다.",
- variant: "destructive",
- })
- router.back()
- return
- }
-
- // 사전견적 상태 체크
- const isPreQuoteStatus = biddingStatus === 'request_for_quotation' || biddingStatus === 'received_quotation'
- if (!isPreQuoteStatus) {
- toast({
- title: "접근 제한",
- description: "사전견적 단계가 아니므로 임시저장이 불가능합니다.",
- variant: "destructive",
- })
- return
- }
-
- if (!userId) {
- toast({
- title: '임시저장 실패',
- description: '사용자 정보를 확인할 수 없습니다. 다시 로그인해주세요.',
- variant: 'destructive',
- })
- return
- }
-
- setIsSaving(true)
- startTransition(async () => {
- try {
- const result = await savePreQuoteDraft(
- biddingDetail.biddingCompanyId!,
- {
- prItemQuotations,
- paymentTermsResponse: responseData.paymentTermsResponse,
- taxConditionsResponse: responseData.taxConditionsResponse,
- incotermsResponse: responseData.incotermsResponse,
- proposedContractDeliveryDate: responseData.proposedContractDeliveryDate,
- proposedShippingPort: responseData.proposedShippingPort,
- proposedDestinationPort: responseData.proposedDestinationPort,
- priceAdjustmentResponse: responseData.priceAdjustmentResponse || false, // 체크 안하면 false로 설정
- isInitialResponse: responseData.isInitialResponse || false, // 체크 안하면 false로 설정
- sparePartResponse: responseData.sparePartResponse,
- additionalProposals: responseData.additionalProposals,
- priceAdjustmentForm: (responseData.priceAdjustmentResponse || false) ? {
- itemName: priceAdjustmentForm.itemName,
- adjustmentReflectionPoint: priceAdjustmentForm.adjustmentReflectionPoint,
- majorApplicableRawMaterial: priceAdjustmentForm.majorApplicableRawMaterial,
- adjustmentFormula: priceAdjustmentForm.adjustmentFormula,
- rawMaterialPriceIndex: priceAdjustmentForm.rawMaterialPriceIndex,
- referenceDate: priceAdjustmentForm.referenceDate,
- comparisonDate: priceAdjustmentForm.comparisonDate,
- adjustmentRatio: priceAdjustmentForm.adjustmentRatio ? parseFloat(priceAdjustmentForm.adjustmentRatio) : undefined,
- notes: priceAdjustmentForm.notes,
- adjustmentConditions: priceAdjustmentForm.adjustmentConditions,
- majorNonApplicableRawMaterial: priceAdjustmentForm.majorNonApplicableRawMaterial,
- adjustmentPeriod: priceAdjustmentForm.adjustmentPeriod,
- contractorWriter: priceAdjustmentForm.contractorWriter,
- adjustmentDate: priceAdjustmentForm.adjustmentDate,
- nonApplicableReason: priceAdjustmentForm.nonApplicableReason,
- } : undefined
- },
- userId
- )
-
- if (result.success) {
- toast({
- title: '임시저장 완료',
- description: result.message,
- })
- } else {
- toast({
- title: '임시저장 실패',
- description: result.error,
- variant: 'destructive',
- })
- }
- } catch (error) {
- console.error('Temp save error:', error)
- toast({
- title: '임시저장 실패',
- description: '서버 오류가 발생했습니다. 잠시 후 다시 시도해주세요.',
- variant: 'destructive',
- })
- } finally {
- setIsSaving(false)
- }
- })
- }
-
- // 사전견적 참여의사 설정 함수
- const handleParticipationDecision = async (participate: boolean) => {
- if (!biddingDetail?.biddingCompanyId) return
-
- startTransition(async () => {
- const result = await setPreQuoteParticipation(
- biddingDetail.biddingCompanyId!,
- participate
- )
-
- if (result.success) {
- setParticipationDecision(participate)
- toast({
- title: '설정 완료',
- description: `사전견적 ${participate ? '참여' : '미참여'}로 설정되었습니다.`,
- })
- } else {
- toast({
- title: '설정 실패',
- description: result.error,
- variant: 'destructive',
- })
- }
- })
- }
-
- const handleSubmitResponse = () => {
- if (!biddingDetail) return
-
- // 입찰 마감 상태 체크
- const biddingStatus = biddingDetail.status
- const isClosed = biddingStatus === 'bidding_closed' || biddingStatus === 'vendor_selected' || biddingStatus === 'bidding_disposal'
-
- if (isClosed) {
- toast({
- title: "접근 제한",
- description: "입찰이 마감되어 더 이상 사전견적을 제출할 수 없습니다.",
- variant: "destructive",
- })
- router.back()
- return
- }
-
- // 사전견적 상태 체크
- const isPreQuoteStatus = biddingStatus === 'request_for_quotation' || biddingStatus === 'received_quotation'
- if (!isPreQuoteStatus) {
- toast({
- title: "접근 제한",
- description: "사전견적 단계가 아니므로 견적 제출이 불가능합니다.",
- variant: "destructive",
- })
- return
- }
-
- // 견적마감일 체크
- if (biddingDetail.preQuoteDeadline) {
- const now = new Date()
- const deadline = new Date(biddingDetail.preQuoteDeadline)
- if (deadline < now) {
- toast({
- title: '견적 마감',
- description: '견적 마감일이 지나 제출할 수 없습니다.',
- variant: 'destructive',
- })
- return
- }
- }
-
- // 필수값 검증
- if (prItemQuotations.length === 0 || totalAmount === 0) {
- toast({
- title: '유효성 오류',
- description: '품목별 견적을 입력해주세요.',
- variant: 'destructive',
- })
- return
- }
-
- // 품목별 납품일 검증
- if (prItemQuotations.length > 0) {
- for (const quotation of prItemQuotations) {
- if (!quotation.proposedDeliveryDate?.trim()) {
- const prItem = prItems.find(item => item.id === quotation.prItemId)
- toast({
- title: '유효성 오류',
- description: `품목 ${prItem?.itemNumber || quotation.prItemId}의 납품예정일을 입력해주세요.`,
- variant: 'destructive',
- })
- return
- }
- }
- }
-
- const requiredFields = [
- { value: responseData.proposedContractDeliveryDate, name: '제안 납품일' },
- { value: responseData.paymentTermsResponse, name: '응답 지급조건' },
- { value: responseData.taxConditionsResponse, name: '응답 세금조건' },
- { value: responseData.incotermsResponse, name: '응답 운송조건' },
- { value: responseData.proposedShippingPort, name: '제안 선적지' },
- { value: responseData.proposedDestinationPort, name: '제안 하역지' },
- { value: responseData.sparePartResponse, name: '스페어파트 응답' },
- ]
-
- const missingField = requiredFields.find(field => !field.value?.trim())
- if (missingField) {
- toast({
- title: '유효성 오류',
- description: `${missingField.name}을(를) 입력해주세요.`,
- variant: 'destructive',
- })
- return
- }
-
- startTransition(async () => {
- const submissionData = {
- preQuoteAmount: totalAmount, // 품목별 계산된 총 금액 사용
- prItemQuotations, // 품목별 견적 데이터 추가
- paymentTermsResponse: responseData.paymentTermsResponse,
- taxConditionsResponse: responseData.taxConditionsResponse,
- incotermsResponse: responseData.incotermsResponse,
- proposedContractDeliveryDate: responseData.proposedContractDeliveryDate,
- proposedShippingPort: responseData.proposedShippingPort,
- proposedDestinationPort: responseData.proposedDestinationPort,
- priceAdjustmentResponse: responseData.priceAdjustmentResponse || false, // 체크 안하면 false로 설정
- isInitialResponse: responseData.isInitialResponse || false, // 체크 안하면 false로 설정
- sparePartResponse: responseData.sparePartResponse,
- additionalProposals: responseData.additionalProposals,
- priceAdjustmentForm: (responseData.priceAdjustmentResponse || false) ? {
- itemName: priceAdjustmentForm.itemName,
- adjustmentReflectionPoint: priceAdjustmentForm.adjustmentReflectionPoint,
- majorApplicableRawMaterial: priceAdjustmentForm.majorApplicableRawMaterial,
- adjustmentFormula: priceAdjustmentForm.adjustmentFormula,
- rawMaterialPriceIndex: priceAdjustmentForm.rawMaterialPriceIndex,
- referenceDate: priceAdjustmentForm.referenceDate,
- comparisonDate: priceAdjustmentForm.comparisonDate,
- adjustmentRatio: priceAdjustmentForm.adjustmentRatio ? parseFloat(priceAdjustmentForm.adjustmentRatio) : undefined,
- notes: priceAdjustmentForm.notes,
- adjustmentConditions: priceAdjustmentForm.adjustmentConditions,
- majorNonApplicableRawMaterial: priceAdjustmentForm.majorNonApplicableRawMaterial,
- adjustmentPeriod: priceAdjustmentForm.adjustmentPeriod,
- contractorWriter: priceAdjustmentForm.contractorWriter,
- adjustmentDate: priceAdjustmentForm.adjustmentDate,
- nonApplicableReason: priceAdjustmentForm.nonApplicableReason,
- } : undefined
- }
-
- const result = await submitPreQuoteResponse(
- biddingDetail.biddingCompanyId!,
- submissionData,
- userId
- )
-
- console.log('제출 결과:', result)
-
- if (result.success) {
- toast({
- title: '성공',
- description: result.message,
- })
-
- // 데이터 새로고침 및 폼 상태 업데이트
- const updatedDetail = await getBiddingCompaniesForPartners(biddingId, companyId)
- console.log('업데이트된 데이터:', updatedDetail)
-
- if (updatedDetail) {
- setBiddingDetail(updatedDetail as BiddingDetail)
-
- // 폼 상태도 업데이트된 데이터로 다시 설정
- setResponseData({
- preQuoteAmount: updatedDetail.preQuoteAmount?.toString() || '',
- paymentTermsResponse: updatedDetail.paymentTermsResponse || '',
- taxConditionsResponse: updatedDetail.taxConditionsResponse || '',
- incotermsResponse: updatedDetail.incotermsResponse || '',
- proposedContractDeliveryDate: updatedDetail.proposedContractDeliveryDate || '',
- proposedShippingPort: updatedDetail.proposedShippingPort || '',
- proposedDestinationPort: updatedDetail.proposedDestinationPort || '',
- priceAdjustmentResponse: updatedDetail.priceAdjustmentResponse || false,
- isInitialResponse: updatedDetail.isInitialResponse || false,
- sparePartResponse: updatedDetail.sparePartResponse || '',
- additionalProposals: updatedDetail.additionalProposals || '',
- isAttendingMeeting: updatedDetail.isAttendingMeeting || false,
- })
-
- // 연동제 데이터도 다시 로드
- if (updatedDetail.biddingCompanyId && updatedDetail.priceAdjustmentResponse) {
- const savedPriceAdjustmentForm = await getPriceAdjustmentFormByBiddingCompanyId(updatedDetail.biddingCompanyId)
- if (savedPriceAdjustmentForm) {
- setPriceAdjustmentForm({
- itemName: savedPriceAdjustmentForm.itemName || '',
- adjustmentReflectionPoint: savedPriceAdjustmentForm.adjustmentReflectionPoint || '',
- majorApplicableRawMaterial: savedPriceAdjustmentForm.majorApplicableRawMaterial || '',
- adjustmentFormula: savedPriceAdjustmentForm.adjustmentFormula || '',
- rawMaterialPriceIndex: savedPriceAdjustmentForm.rawMaterialPriceIndex || '',
- referenceDate: savedPriceAdjustmentForm.referenceDate ? new Date(savedPriceAdjustmentForm.referenceDate).toISOString().split('T')[0] : '',
- comparisonDate: savedPriceAdjustmentForm.comparisonDate ? new Date(savedPriceAdjustmentForm.comparisonDate).toISOString().split('T')[0] : '',
- adjustmentRatio: savedPriceAdjustmentForm.adjustmentRatio?.toString() || '',
- notes: savedPriceAdjustmentForm.notes || '',
- adjustmentConditions: savedPriceAdjustmentForm.adjustmentConditions || '',
- majorNonApplicableRawMaterial: savedPriceAdjustmentForm.majorNonApplicableRawMaterial || '',
- adjustmentPeriod: savedPriceAdjustmentForm.adjustmentPeriod || '',
- contractorWriter: savedPriceAdjustmentForm.contractorWriter || '',
- adjustmentDate: savedPriceAdjustmentForm.adjustmentDate ? new Date(savedPriceAdjustmentForm.adjustmentDate).toISOString().split('T')[0] : '',
- nonApplicableReason: savedPriceAdjustmentForm.nonApplicableReason || '',
- })
- }
- }
- }
- } else {
- toast({
- title: '오류',
- description: result.error,
- variant: 'destructive',
- })
- }
- })
- }
-
-
- if (isLoading) {
- return (
- <div className="flex items-center justify-center py-12">
- <div className="text-center">
- <div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary mx-auto mb-4"></div>
- <p className="text-muted-foreground">입찰 정보를 불러오는 중...</p>
- </div>
- </div>
- )
- }
-
- if (!biddingDetail) {
- return (
- <div className="text-center py-12">
- <p className="text-muted-foreground">입찰 정보를 찾을 수 없습니다.</p>
- <Button onClick={() => router.back()} className="mt-4">
- <ArrowLeft className="w-4 h-4 mr-2" />
- 돌아가기
- </Button>
- </div>
- )
- }
-
- return (
- <div className="space-y-6">
- {/* 헤더 */}
- <div className="flex items-center justify-between">
- <div className="flex items-center gap-4">
- <Button variant="outline" onClick={() => router.back()}>
- <ArrowLeft className="w-4 h-4 mr-2" />
- 목록으로
- </Button>
- <div>
- <h1 className="text-2xl font-semibold">{biddingDetail.title}</h1>
- <div className="flex items-center gap-2 mt-1">
- <Badge variant="outline" className="font-mono">
- {biddingDetail.biddingNumber}
- {biddingDetail.revision && biddingDetail.revision > 0 && ` Rev.${biddingDetail.revision}`}
- </Badge>
- <Badge variant={
- biddingDetail.status === 'bidding_disposal' ? 'destructive' :
- biddingDetail.status === 'vendor_selected' ? 'default' :
- 'secondary'
- }>
- {biddingStatusLabels[biddingDetail.status]}
- </Badge>
- </div>
- </div>
- </div>
-
- </div>
-
- {/* 입찰 공고 섹션 */}
- <Card>
- <CardHeader>
- <CardTitle className="flex items-center gap-2">
- <FileText className="w-5 h-5" />
- 입찰 공고
- </CardTitle>
- </CardHeader>
- <CardContent className="space-y-4">
- <div className="grid grid-cols-1 md:grid-cols-2 gap-4">
- <div>
- <Label className="text-sm font-medium text-muted-foreground">프로젝트</Label>
- <div className="flex items-center gap-2 mt-1">
- <Building2 className="w-4 h-4" />
- <span>{biddingDetail.projectName}</span>
- </div>
- </div>
- <div>
- <Label className="text-sm font-medium text-muted-foreground">품목</Label>
- <div className="flex items-center gap-2 mt-1">
- <Package className="w-4 h-4" />
- <span>{biddingDetail.itemName}</span>
- </div>
- </div>
- {/* <div>
- <Label className="text-sm font-medium text-muted-foreground">계약구분</Label>
- <div className="mt-1">{contractTypeLabels[biddingDetail.contractType]}</div>
- </div>
- <div>
- <Label className="text-sm font-medium text-muted-foreground">입찰유형</Label>
- <div className="mt-1">{biddingTypeLabels[biddingDetail.biddingType]}</div>
- </div>
- <div>
- <Label className="text-sm font-medium text-muted-foreground">낙찰수</Label>
- <div className="mt-1">{biddingDetail.awardCount === 'single' ? '단수' : '복수'}</div>
- </div> */}
- <div>
- <Label className="text-sm font-medium text-muted-foreground">담당자</Label>
- <div className="flex items-center gap-2 mt-1">
- <User className="w-4 h-4" />
- <span>{biddingDetail.managerName}</span>
- </div>
- </div>
- </div>
-
- {/* {biddingDetail.budget && (
- <div>
- <Label className="text-sm font-medium text-muted-foreground">예산</Label>
- <div className="flex items-center gap-2 mt-1">
- <DollarSign className="w-4 h-4" />
- <span className="font-semibold">{formatCurrency(biddingDetail.budget)}</span>
- </div>
- </div>
- )} */}
-
- {/* 일정 정보 */}
- {/* <div className="pt-4 border-t">
- <Label className="text-sm font-medium text-muted-foreground mb-2 block">일정 정보</Label>
- <div className="grid grid-cols-1 md:grid-cols-2 gap-2 text-sm">
- {biddingDetail.submissionStartDate && biddingDetail.submissionEndDate && (
- <div>
- <span className="font-medium">제출기간:</span> {formatDate(biddingDetail.submissionStartDate, 'KR')} ~ {formatDate(biddingDetail.submissionEndDate, 'KR')}
- </div>
- )}
- {biddingDetail.evaluationDate && (
- <div>
- <span className="font-medium">평가일:</span> {formatDate(biddingDetail.evaluationDate, 'KR')}
- </div>
- )}
- </div>
- </div> */}
-
- {/* 견적마감일 정보 */}
- {biddingDetail.preQuoteDeadline && (
- <div className="pt-4 border-t">
- <Label className="text-sm font-medium text-muted-foreground mb-2 block">견적 마감 정보</Label>
- {(() => {
- const now = new Date()
- const deadline = new Date(biddingDetail.preQuoteDeadline)
- const isExpired = deadline < now
- const timeLeft = deadline.getTime() - now.getTime()
- const daysLeft = Math.floor(timeLeft / (1000 * 60 * 60 * 24))
- const hoursLeft = Math.floor((timeLeft % (1000 * 60 * 60 * 24)) / (1000 * 60 * 60))
-
- return (
- <div className={`p-3 rounded-lg border-2 ${
- isExpired
- ? 'border-red-200 bg-red-50'
- : daysLeft <= 1
- ? 'border-orange-200 bg-orange-50'
- : 'border-green-200 bg-green-50'
- }`}>
- <div className="flex items-center justify-between">
- <div className="flex items-center gap-2">
- <Calendar className="w-5 h-5" />
- <span className="font-medium">견적 마감일:</span>
- <span className="text-lg font-semibold">
- {formatDate(biddingDetail.preQuoteDeadline, 'KR')}
- </span>
- </div>
- {isExpired ? (
- <Badge variant="destructive" className="ml-2">
- 마감됨
- </Badge>
- ) : daysLeft <= 1 ? (
- <Badge variant="secondary" className="ml-2 bg-orange-100 text-orange-800">
- {daysLeft === 0 ? `${hoursLeft}시간 남음` : `${daysLeft}일 남음`}
- </Badge>
- ) : (
- <Badge variant="secondary" className="ml-2 bg-green-100 text-green-800">
- {daysLeft}일 남음
- </Badge>
- )}
- </div>
- {isExpired && (
- <div className="mt-2 text-sm text-red-600">
- ⚠️ 견적 마감일이 지났습니다. 견적 제출이 불가능합니다.
- </div>
- )}
- </div>
- )
- })()}
- </div>
- )}
- </CardContent>
- </Card>
-
- {/* 현재 설정된 조건 섹션 */}
- {biddingConditions && (
- <Card>
- <CardHeader>
- <CardTitle>현재 설정된 입찰 조건</CardTitle>
- </CardHeader>
- <CardContent>
- <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4 text-sm">
- <div>
- <Label className="text-muted-foreground">지급조건</Label>
- <div className="mt-1 p-3 bg-muted rounded-md">
- <p className="font-medium">{biddingConditions.paymentTerms || "미설정"}</p>
- </div>
- </div>
-
- <div>
- <Label className="text-muted-foreground">세금조건</Label>
- <div className="mt-1 p-3 bg-muted rounded-md">
- <p className="font-medium">
- {biddingConditions.taxConditions
- ? getTaxConditionName(biddingConditions.taxConditions)
- : "미설정"
- }
- </p>
- </div>
- </div>
-
- <div>
- <Label className="text-muted-foreground">운송조건</Label>
- <div className="mt-1 p-3 bg-muted rounded-md">
- <p className="font-medium">{biddingConditions.incoterms || "미설정"}</p>
- </div>
- </div>
-
- <div>
- <Label className="text-muted-foreground">계약 납기일</Label>
- <div className="mt-1 p-3 bg-muted rounded-md">
- <p className="font-medium">
- {biddingConditions.contractDeliveryDate
- ? formatDate(biddingConditions.contractDeliveryDate, 'KR')
- : "미설정"
- }
- </p>
- </div>
- </div>
-
- <div>
- <Label className="text-muted-foreground">선적지</Label>
- <div className="mt-1 p-3 bg-muted rounded-md">
- <p className="font-medium">{biddingConditions.shippingPort || "미설정"}</p>
- </div>
- </div>
-
- <div>
- <Label className="text-muted-foreground">하역지</Label>
- <div className="mt-1 p-3 bg-muted rounded-md">
- <p className="font-medium">{biddingConditions.destinationPort || "미설정"}</p>
- </div>
- </div>
-
- <div>
- <Label className="text-muted-foreground">연동제 적용</Label>
- <div className="mt-1 p-3 bg-muted rounded-md">
- <p className="font-medium">{biddingConditions.isPriceAdjustmentApplicable ? "적용 가능" : "적용 불가"}</p>
- </div>
- </div>
-
-
- <div >
- <Label className="text-muted-foreground">스페어파트 옵션</Label>
- <div className="mt-1 p-3 bg-muted rounded-md">
- <p className="font-medium">{biddingConditions.sparePartOptions}</p>
- </div>
- </div>
- </div>
- </CardContent>
- </Card>
- )}
-
- {/* 사전견적 참여의사 결정 섹션 */}
- <Card>
- <CardHeader>
- <CardTitle className="flex items-center gap-2">
- <Users className="w-5 h-5" />
- 사전견적 참여의사 결정
- </CardTitle>
- </CardHeader>
- <CardContent>
- {participationDecision === null ? (
- <div className="space-y-4">
- <p className="text-muted-foreground">
- 해당 입찰의 사전견적에 참여하시겠습니까?
- </p>
- <div className="flex gap-3">
- <Button
- onClick={() => handleParticipationDecision(true)}
- disabled={isPending}
- className="flex items-center gap-2"
- >
- <CheckCircle className="w-4 h-4" />
- 참여
- </Button>
- <Button
- variant="outline"
- onClick={() => handleParticipationDecision(false)}
- disabled={isPending}
- className="flex items-center gap-2"
- >
- <XCircle className="w-4 h-4" />
- 미참여
- </Button>
- </div>
- </div>
- ) : (
- <div className="space-y-4">
- <div className={`flex items-center gap-2 p-3 rounded-lg ${
- participationDecision ? 'bg-green-50 text-green-800' : 'bg-red-50 text-red-800'
- }`}>
- {participationDecision ? (
- <CheckCircle className="w-5 h-5" />
- ) : (
- <XCircle className="w-5 h-5" />
- )}
- <span className="font-medium">
- 사전견적 {participationDecision ? '참여' : '미참여'}로 설정되었습니다.
- </span>
- </div>
- {participationDecision === false && (
- <>
- <div className="p-4 bg-muted rounded-lg">
- <p className="text-muted-foreground">
- 미참여로 설정되어 견적 작성 섹션이 숨겨집니다. 참여하시려면 아래 버튼을 클릭해주세요.
- </p>
- </div>
-
- <Button
- variant="outline"
- size="sm"
- onClick={() => setParticipationDecision(null)}
- disabled={isPending}
- >
- 결정 변경하기
- </Button>
- </>
- )}
- </div>
- )}
- </CardContent>
- </Card>
-
- {/* 참여 결정 시에만 견적 작성 섹션들 표시 (단, 견적마감일이 지나지 않은 경우에만) */}
- {participationDecision === true && (() => {
- // 견적마감일 체크
- if (biddingDetail?.preQuoteDeadline) {
- const now = new Date()
- const deadline = new Date(biddingDetail.preQuoteDeadline)
- const isExpired = deadline < now
-
- if (isExpired) {
- return (
- <Card>
- <CardContent className="pt-6">
- <div className="text-center py-8">
- <XCircle className="w-12 h-12 text-red-500 mx-auto mb-4" />
- <h3 className="text-lg font-semibold text-red-700 mb-2">견적 마감</h3>
- <p className="text-muted-foreground">
- 견적 마감일({formatDate(biddingDetail.preQuoteDeadline, 'KR')})이 지나 견적 제출이 불가능합니다.
- </p>
- </div>
- </CardContent>
- </Card>
- )
- }
- }
-
- return true // 견적 작성 가능
- })() && (
- <>
- {/* 품목별 견적 작성 섹션 */}
- {prItems.length > 0 && (
- <PrItemsPricingTable
- prItems={prItems}
- initialQuotations={prItemQuotations}
- currency={biddingDetail?.currency || 'KRW'}
- onQuotationsChange={setPrItemQuotations}
- onTotalAmountChange={setTotalAmount}
- readOnly={false}
- />
- )}
-
- {/* 견적 문서 업로드 섹션 */}
- <SimpleFileUpload
- biddingId={biddingId}
- companyId={companyId}
- userId={userId}
- readOnly={false}
- />
-
- {/* 사전견적 폼 섹션 */}
- <Card>
- <CardHeader>
- <CardTitle className="flex items-center gap-2">
- <Send className="w-5 h-5" />
- 사전견적 제출하기
- </CardTitle>
- </CardHeader>
- <CardContent className="space-y-6">
- {/* 총 금액 표시 (읽기 전용) */}
- <div className="grid grid-cols-1 md:grid-cols-2 gap-6">
- <div className="space-y-2">
- <Label htmlFor="totalAmount">총 사전견적 금액 <span className="text-red-500">*</span></Label>
- <Input
- id="totalAmount"
- type="text"
- value={new Intl.NumberFormat('ko-KR', {
- style: 'currency',
- currency: biddingDetail?.currency || 'KRW',
- }).format(totalAmount)}
- readOnly
- className="bg-gray-50 font-semibold text-primary"
- />
- </div>
-
- <div className="space-y-2">
- <Label htmlFor="proposedContractDeliveryDate">제안 납품일 <span className="text-red-500">*</span></Label>
- <Input
- id="proposedContractDeliveryDate"
- type="date"
- value={responseData.proposedContractDeliveryDate}
- onChange={(e) => setResponseData({...responseData, proposedContractDeliveryDate: e.target.value})}
- title={biddingConditions?.contractDeliveryDate ? `참고 납기일: ${formatDate(biddingConditions.contractDeliveryDate, 'KR')}` : "납품일을 선택하세요"}
- />
- {biddingConditions?.contractDeliveryDate && (
- <p className="text-xs text-muted-foreground">
- 참고 납기일: {formatDate(biddingConditions.contractDeliveryDate, 'KR')}
- </p>
- )}
- </div>
- </div>
-
- <div className="grid grid-cols-1 md:grid-cols-2 gap-6">
- <div className="space-y-2">
- <Label htmlFor="paymentTermsResponse">응답 지급조건 <span className="text-red-500">*</span></Label>
- <Select
- value={responseData.paymentTermsResponse}
- onValueChange={(value) => setResponseData({...responseData, paymentTermsResponse: value})}
- >
- <SelectTrigger>
- <SelectValue placeholder={biddingConditions?.paymentTerms ? `참고: ${biddingConditions.paymentTerms}` : "지급조건 선택"} />
- </SelectTrigger>
- <SelectContent>
- {paymentTermsOptions.length > 0 ? (
- paymentTermsOptions.map((option) => (
- <SelectItem key={option.code} value={option.code}>
- {option.code} {option.description && `(${option.description})`}
- </SelectItem>
- ))
- ) : (
- <SelectItem value="loading" disabled>
- 데이터를 불러오는 중...
- </SelectItem>
- )}
- </SelectContent>
- </Select>
- </div>
-
- <div className="space-y-2">
- <Label htmlFor="taxConditionsResponse">응답 세금조건 <span className="text-red-500">*</span></Label>
- <Select
- value={responseData.taxConditionsResponse}
- onValueChange={(value) => setResponseData({...responseData, taxConditionsResponse: value})}
- >
- <SelectTrigger>
- <SelectValue placeholder={biddingConditions?.taxConditions ? `참고: ${getTaxConditionName(biddingConditions.taxConditions)}` : "세금조건 선택"} />
- </SelectTrigger>
- <SelectContent>
- {TAX_CONDITIONS.map((condition) => (
- <SelectItem key={condition.code} value={condition.code}>
- {condition.name}
- </SelectItem>
- ))}
- </SelectContent>
- </Select>
- </div>
- </div>
-
- <div className="grid grid-cols-1 md:grid-cols-2 gap-6">
- <div className="space-y-2">
- <Label htmlFor="incotermsResponse">응답 운송조건 <span className="text-red-500">*</span></Label>
- <Select
- value={responseData.incotermsResponse}
- onValueChange={(value) => setResponseData({...responseData, incotermsResponse: value})}
- >
- <SelectTrigger>
- <SelectValue placeholder={biddingConditions?.incoterms ? `참고: ${biddingConditions.incoterms}` : "운송조건 선택"} />
- </SelectTrigger>
- <SelectContent>
- {incotermsOptions.length > 0 ? (
- incotermsOptions.map((option) => (
- <SelectItem key={option.code} value={option.code}>
- {option.code} {option.description && `(${option.description})`}
- </SelectItem>
- ))
- ) : (
- <SelectItem value="loading" disabled>
- 데이터를 불러오는 중...
- </SelectItem>
- )}
- </SelectContent>
- </Select>
- </div>
-
- <div className="space-y-2">
- <Label htmlFor="proposedShippingPort">제안 선적지 <span className="text-red-500">*</span></Label>
- <Select
- value={responseData.proposedShippingPort}
- onValueChange={(value) => setResponseData({...responseData, proposedShippingPort: value})}
- >
- <SelectTrigger>
- <SelectValue placeholder={biddingConditions?.shippingPort ? `참고: ${biddingConditions.shippingPort}` : "선적지 선택"} />
- </SelectTrigger>
- <SelectContent>
- {shippingPlaces.length > 0 ? (
- shippingPlaces.map((place) => (
- <SelectItem key={place.code} value={place.code}>
- {place.code} {place.description && `(${place.description})`}
- </SelectItem>
- ))
- ) : (
- <SelectItem value="loading" disabled>
- 데이터를 불러오는 중...
- </SelectItem>
- )}
- </SelectContent>
- </Select>
- </div>
- </div>
-
- <div className="grid grid-cols-1 md:grid-cols-2 gap-6">
- <div className="space-y-2">
- <Label htmlFor="proposedDestinationPort">제안 하역지 <span className="text-red-500">*</span></Label>
- <Select
- value={responseData.proposedDestinationPort}
- onValueChange={(value) => setResponseData({...responseData, proposedDestinationPort: value})}
- >
- <SelectTrigger>
- <SelectValue placeholder={biddingConditions?.destinationPort ? `참고: ${biddingConditions.destinationPort}` : "하역지 선택"} />
- </SelectTrigger>
- <SelectContent>
- {destinationPlaces.length > 0 ? (
- destinationPlaces.map((place) => (
- <SelectItem key={place.code} value={place.code}>
- {place.code} {place.description && `(${place.description})`}
- </SelectItem>
- ))
- ) : (
- <SelectItem value="loading" disabled>
- 데이터를 불러오는 중...
- </SelectItem>
- )}
- </SelectContent>
- </Select>
- </div>
-
- <div className="space-y-2">
- <Label htmlFor="sparePartResponse">스페어파트 응답 <span className="text-red-500">*</span></Label>
- <Input
- id="sparePartResponse"
- value={responseData.sparePartResponse}
- onChange={(e) => setResponseData({...responseData, sparePartResponse: e.target.value})}
- placeholder={biddingConditions?.sparePartOptions ? `참고: ${biddingConditions.sparePartOptions}` : "스페어파트 관련 응답을 입력하세요"}
- />
- </div>
- </div>
-
- <div className="space-y-2">
- <Label htmlFor="additionalProposals">변경사유</Label>
- <Textarea
- id="additionalProposals"
- value={responseData.additionalProposals}
- onChange={(e) => setResponseData({...responseData, additionalProposals: e.target.value})}
- placeholder="변경사유를 입력하세요"
- rows={4}
- />
- </div>
-
- <div className="space-y-4">
- <div className="flex items-center space-x-2">
- <Checkbox
- id="isInitialResponse"
- checked={responseData.isInitialResponse}
- onCheckedChange={(checked) =>
- setResponseData({...responseData, isInitialResponse: !!checked})
- }
- />
- <Label htmlFor="isInitialResponse">초도 공급입니다</Label>
- </div>
-
- <div className="flex items-center space-x-2">
- <Checkbox
- id="priceAdjustmentResponse"
- checked={responseData.priceAdjustmentResponse}
- onCheckedChange={(checked) =>
- setResponseData({...responseData, priceAdjustmentResponse: !!checked})
- }
- />
- <Label htmlFor="priceAdjustmentResponse">연동제 적용에 동의합니다</Label>
- </div>
- </div>
-
- {/* 연동제 상세 정보 (연동제 적용 시에만 표시) */}
- {responseData.priceAdjustmentResponse && (
- <Card className="mt-6">
- <CardHeader>
- <CardTitle className="text-lg">하도급대금등 연동표</CardTitle>
- </CardHeader>
- <CardContent className="space-y-4">
- <div className="grid grid-cols-1 md:grid-cols-2 gap-4">
- <div className="space-y-2">
- <Label htmlFor="itemName">품목등의 명칭</Label>
- <Input
- id="itemName"
- value={priceAdjustmentForm.itemName}
- onChange={(e) => setPriceAdjustmentForm({...priceAdjustmentForm, itemName: e.target.value})}
- placeholder="품목명을 입력하세요"
- />
- </div>
-
- <div className="space-y-2">
- <Label htmlFor="adjustmentReflectionPoint">조정대금 반영시점</Label>
- <Input
- id="adjustmentReflectionPoint"
- value={priceAdjustmentForm.adjustmentReflectionPoint}
- onChange={(e) => setPriceAdjustmentForm({...priceAdjustmentForm, adjustmentReflectionPoint: e.target.value})}
- placeholder="반영시점을 입력하세요"
- />
- </div>
-
- <div className="space-y-2">
- <Label htmlFor="adjustmentRatio">연동 비율 (%)</Label>
- <Input
- id="adjustmentRatio"
- type="number"
- step="0.01"
- value={priceAdjustmentForm.adjustmentRatio}
- onChange={(e) => setPriceAdjustmentForm({...priceAdjustmentForm, adjustmentRatio: e.target.value})}
- placeholder="비율을 입력하세요"
- />
- </div>
-
- <div className="space-y-2">
- <Label htmlFor="adjustmentPeriod">조정주기</Label>
- <Input
- id="adjustmentPeriod"
- value={priceAdjustmentForm.adjustmentPeriod}
- onChange={(e) => setPriceAdjustmentForm({...priceAdjustmentForm, adjustmentPeriod: e.target.value})}
- placeholder="조정주기를 입력하세요"
- />
- </div>
-
- <div className="space-y-2">
- <Label htmlFor="referenceDate">기준시점</Label>
- <Input
- id="referenceDate"
- type="date"
- value={priceAdjustmentForm.referenceDate}
- onChange={(e) => setPriceAdjustmentForm({...priceAdjustmentForm, referenceDate: e.target.value})}
- />
- </div>
-
- <div className="space-y-2">
- <Label htmlFor="comparisonDate">비교시점</Label>
- <Input
- id="comparisonDate"
- type="date"
- value={priceAdjustmentForm.comparisonDate}
- onChange={(e) => setPriceAdjustmentForm({...priceAdjustmentForm, comparisonDate: e.target.value})}
- />
- </div>
-
- <div className="space-y-2">
- <Label htmlFor="contractorWriter">수탁기업(협력사) 작성자</Label>
- <Input
- id="contractorWriter"
- value={priceAdjustmentForm.contractorWriter}
- onChange={(e) => setPriceAdjustmentForm({...priceAdjustmentForm, contractorWriter: e.target.value})}
- placeholder="작성자명을 입력하세요"
- />
- </div>
-
- <div className="space-y-2">
- <Label htmlFor="adjustmentDate">조정일</Label>
- <Input
- id="adjustmentDate"
- type="date"
- value={priceAdjustmentForm.adjustmentDate}
- onChange={(e) => setPriceAdjustmentForm({...priceAdjustmentForm, adjustmentDate: e.target.value})}
- />
- </div>
- </div>
-
- <div className="space-y-2">
- <Label htmlFor="majorApplicableRawMaterial">연동대상 주요 원재료</Label>
- <Textarea
- id="majorApplicableRawMaterial"
- value={priceAdjustmentForm.majorApplicableRawMaterial}
- onChange={(e) => setPriceAdjustmentForm({...priceAdjustmentForm, majorApplicableRawMaterial: e.target.value})}
- placeholder="연동 대상 원재료를 입력하세요"
- rows={3}
- />
- </div>
-
- <div className="space-y-2">
- <Label htmlFor="adjustmentFormula">하도급대금등 연동 산식</Label>
- <Textarea
- id="adjustmentFormula"
- value={priceAdjustmentForm.adjustmentFormula}
- onChange={(e) => setPriceAdjustmentForm({...priceAdjustmentForm, adjustmentFormula: e.target.value})}
- placeholder="연동 산식을 입력하세요"
- rows={3}
- />
- </div>
-
- <div className="space-y-2">
- <Label htmlFor="rawMaterialPriceIndex">원재료 가격 기준지표</Label>
- <Textarea
- id="rawMaterialPriceIndex"
- value={priceAdjustmentForm.rawMaterialPriceIndex}
- onChange={(e) => setPriceAdjustmentForm({...priceAdjustmentForm, rawMaterialPriceIndex: e.target.value})}
- placeholder="가격 기준지표를 입력하세요"
- rows={2}
- />
- </div>
-
- <div className="space-y-2">
- <Label htmlFor="adjustmentConditions">조정요건</Label>
- <Textarea
- id="adjustmentConditions"
- value={priceAdjustmentForm.adjustmentConditions}
- onChange={(e) => setPriceAdjustmentForm({...priceAdjustmentForm, adjustmentConditions: e.target.value})}
- placeholder="조정요건을 입력하세요"
- rows={2}
- />
- </div>
-
- <div className="space-y-2">
- <Label htmlFor="majorNonApplicableRawMaterial">연동 미적용 주요 원재료</Label>
- <Textarea
- id="majorNonApplicableRawMaterial"
- value={priceAdjustmentForm.majorNonApplicableRawMaterial}
- onChange={(e) => setPriceAdjustmentForm({...priceAdjustmentForm, majorNonApplicableRawMaterial: e.target.value})}
- placeholder="연동 미적용 원재료를 입력하세요"
- rows={2}
- />
- </div>
-
- <div className="space-y-2">
- <Label htmlFor="nonApplicableReason">연동 미적용 사유</Label>
- <Textarea
- id="nonApplicableReason"
- value={priceAdjustmentForm.nonApplicableReason}
- onChange={(e) => setPriceAdjustmentForm({...priceAdjustmentForm, nonApplicableReason: e.target.value})}
- placeholder="미적용 사유를 입력하세요"
- rows={2}
- />
- </div>
-
- <div className="space-y-2">
- <Label htmlFor="priceAdjustmentNotes">기타 사항</Label>
- <Textarea
- id="priceAdjustmentNotes"
- value={priceAdjustmentForm.notes}
- onChange={(e) => setPriceAdjustmentForm({...priceAdjustmentForm, notes: e.target.value})}
- placeholder="기타 사항을 입력하세요"
- rows={2}
- />
- </div>
- </CardContent>
- </Card>
- )}
-
- <div className="flex justify-end gap-2 pt-4">
- <Button
- variant="outline"
- onClick={handleTempSave}
- disabled={isSaving || isPending || (biddingDetail && !['request_for_quotation', 'received_quotation'].includes(biddingDetail.status))}
- >
- <Save className="w-4 h-4 mr-2" />
- {isSaving ? '저장중...' : '임시저장'}
- </Button>
- <Button
- onClick={handleSubmitResponse}
- disabled={isPending || isSaving || (biddingDetail && !['request_for_quotation', 'received_quotation'].includes(biddingDetail.status))}
- >
- <Send className="w-4 h-4 mr-2" />
- 사전견적 제출
- </Button>
- </div>
- </CardContent>
- </Card>
- </>
- )}
- </div>
- )
-}