diff options
Diffstat (limited to 'lib/bidding')
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> - ) -} |
