diff options
| author | dujinkim <dujin.kim@dtsolution.co.kr> | 2025-09-08 10:29:19 +0000 |
|---|---|---|
| committer | dujinkim <dujin.kim@dtsolution.co.kr> | 2025-09-08 10:29:19 +0000 |
| commit | f93493f68c9f368e10f1c3379f1c1384068e3b14 (patch) | |
| tree | a9dada58741750fa7ca6e04b210443ad99a6bccc /lib/bidding | |
| parent | e832a508e1b3c531fb3e1b9761e18e1b55e3d76a (diff) | |
(대표님, 최겸) rfqLast, bidding, prequote
Diffstat (limited to 'lib/bidding')
22 files changed, 2818 insertions, 1545 deletions
diff --git a/lib/bidding/detail/service.ts b/lib/bidding/detail/service.ts index 7c7ae498..956c1798 100644 --- a/lib/bidding/detail/service.ts +++ b/lib/bidding/detail/service.ts @@ -4,43 +4,50 @@ import db from '@/db/db' import { biddings, prItemsForBidding, biddingDocuments, biddingCompanies, vendors, companyPrItemBids, companyConditionResponses, vendorSelectionResults, BiddingListItem, biddingConditions, priceAdjustmentForms } from '@/db/schema' import { specificationMeetings } from '@/db/schema/bidding' import { eq, and, sql, desc, ne } from 'drizzle-orm' -import { revalidatePath } from 'next/cache' +import { revalidatePath, revalidateTag } from 'next/cache' +import { unstable_cache } from "@/lib/unstable-cache"; +import { sendEmail } from '@/lib/mail/sendEmail' +import { saveFile } from '@/lib/file-stroage' // 데이터 조회 함수들 export interface BiddingDetailData { bidding: Awaited<ReturnType<typeof getBiddingById>> quotationDetails: QuotationDetails | null quotationVendors: QuotationVendor[] - biddingCompanies: Awaited<ReturnType<typeof getBiddingCompaniesData>> prItems: Awaited<ReturnType<typeof getPRItemsForBidding>> } // getBiddingById 함수 임포트 (기존 함수 재사용) import { getBiddingById, getPRDetailsAction } from '@/lib/bidding/service' -// Promise.all을 사용하여 모든 데이터를 병렬로 조회 +// Promise.all을 사용하여 모든 데이터를 병렬로 조회 (캐시 적용) export async function getBiddingDetailData(biddingId: number): Promise<BiddingDetailData> { - const [ - bidding, - quotationDetails, - quotationVendors, - biddingCompanies, - prItems - ] = await Promise.all([ - getBiddingById(biddingId), - getQuotationDetails(biddingId), - getQuotationVendors(biddingId), - getBiddingCompaniesData(biddingId), - getPRItemsForBidding(biddingId) - ]) + return unstable_cache( + async () => { + const [ + bidding, + quotationDetails, + quotationVendors, + prItems + ] = await Promise.all([ + getBiddingById(biddingId), + getQuotationDetails(biddingId), + getQuotationVendors(biddingId), + getPRItemsForBidding(biddingId) + ]) - return { - bidding, - quotationDetails, - quotationVendors, - biddingCompanies, - prItems - } + return { + bidding, + quotationDetails, + quotationVendors, + prItems + } + }, + [`bidding-detail-data-${biddingId}`], + { + tags: [`bidding-${biddingId}`, 'bidding-detail', 'quotation-vendors', 'pr-items'] + } + )() } export interface QuotationDetails { biddingId: number @@ -65,18 +72,9 @@ export interface QuotationVendor { currency: string submissionDate: string // 제출일 isWinner: boolean // 낙찰여부 - awardRatio: number // 발주비율 + awardRatio: number | null // 발주비율 + isBiddingParticipated: boolean | null // 본입찰 참여여부 status: 'pending' | 'submitted' | 'selected' | 'rejected' - // companyConditionResponses에서 가져온 입찰 조건들 - paymentTermsResponse?: string // 지급조건 응답 - taxConditionsResponse?: string // 세금조건 응답 - incotermsResponse?: string // 운송조건 응답 - proposedContractDeliveryDate?: string // 제안 계약납기일 - proposedShippingPort?: string // 제안 선적지 - proposedDestinationPort?: string // 제안 도착지 - priceAdjustmentResponse?: boolean // 연동제 적용 응답 - sparePartResponse?: string // 스페어파트 응답 - additionalProposals?: string // 추가 제안사항 documents: Array<{ id: number fileName: string @@ -86,8 +84,10 @@ export interface QuotationVendor { }> } -// 견적 시스템에서 내정가 및 관련 정보를 가져오는 함수 +// 견적 시스템에서 내정가 및 관련 정보를 가져오는 함수 (캐시 적용) export async function getQuotationDetails(biddingId: number): Promise<QuotationDetails | null> { + return unstable_cache( + async () => { try { // bidding_companies 테이블에서 견적 데이터를 집계 const quotationStats = await db @@ -136,6 +136,12 @@ export async function getQuotationDetails(biddingId: number): Promise<QuotationD console.error('Failed to get quotation details:', error) return null } + }, + [`quotation-details-${biddingId}`], + { + tags: [`bidding-${biddingId}`, 'quotation-details'] + } + )() } // bidding_companies 테이블을 메인으로 vendors 테이블을 조인하여 협력업체 정보 조회 @@ -166,8 +172,14 @@ export async function getBiddingCompaniesData(biddingId: number) { }) .from(biddingCompanies) .leftJoin(vendors, eq(biddingCompanies.companyId, vendors.id)) - .where(eq(biddingCompanies.biddingId, biddingId)) + .where( + and( + eq(biddingCompanies.biddingId, biddingId), + eq(biddingCompanies.isPreQuoteSelected, true) + ) + ) .orderBy(desc(biddingCompanies.finalQuoteAmount)) + console.log(companies) return companies } catch (error) { @@ -176,26 +188,36 @@ export async function getBiddingCompaniesData(biddingId: number) { } } -// prItemsForBidding 테이블에서 품목 정보 조회 +// prItemsForBidding 테이블에서 품목 정보 조회 (캐시 적용) export async function getPRItemsForBidding(biddingId: number) { - try { - const items = await db - .select() - .from(prItemsForBidding) - .where(eq(prItemsForBidding.biddingId, biddingId)) - .orderBy(prItemsForBidding.id) - - return items - } catch (error) { - console.error('Failed to get PR items for bidding:', error) - return [] - } + return unstable_cache( + async () => { + try { + const items = await db + .select() + .from(prItemsForBidding) + .where(eq(prItemsForBidding.biddingId, biddingId)) + .orderBy(prItemsForBidding.id) + + return items + } catch (error) { + console.error('Failed to get PR items for bidding:', error) + return [] + } + }, + [`pr-items-for-bidding-${biddingId}`], + { + tags: [`bidding-${biddingId}`, 'pr-items'] + } + )() } -// 견적 시스템에서 협력업체 정보를 가져오는 함수 +// 견적 시스템에서 협력업체 정보를 가져오는 함수 (캐시 적용) export async function getQuotationVendors(biddingId: number): Promise<QuotationVendor[]> { + return unstable_cache( + async () => { try { - // bidding_companies 테이블을 메인으로 vendors, company_condition_responses를 조인하여 협력업체 정보 조회 + // bidding_companies 테이블을 메인으로 vendors를 조인하여 협력업체 정보 조회 const vendorsData = await db .select({ id: biddingCompanies.id, @@ -207,32 +229,25 @@ export async function getQuotationVendors(biddingId: number): Promise<QuotationV contactEmail: biddingCompanies.contactEmail, contactPhone: biddingCompanies.contactPhone, quotationAmount: biddingCompanies.finalQuoteAmount, - currency: sql<string>`'KRW'` as currency, + currency: sql<string>`'KRW'`, submissionDate: biddingCompanies.finalQuoteSubmittedAt, isWinner: biddingCompanies.isWinner, - awardRatio: sql<number>`CASE WHEN ${biddingCompanies.isWinner} THEN 100 ELSE 0 END`, + // 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`, - // companyConditionResponses에서 입찰 조건들 - paymentTermsResponse: companyConditionResponses.paymentTermsResponse, - taxConditionsResponse: companyConditionResponses.taxConditionsResponse, - incotermsResponse: companyConditionResponses.incotermsResponse, - proposedContractDeliveryDate: companyConditionResponses.proposedContractDeliveryDate, - proposedShippingPort: companyConditionResponses.proposedShippingPort, - proposedDestinationPort: companyConditionResponses.proposedDestinationPort, - priceAdjustmentResponse: companyConditionResponses.priceAdjustmentResponse, - isInitialResponse: companyConditionResponses.isInitialResponse, - 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)) + .where(and( + eq(biddingCompanies.biddingId, biddingId), + eq(biddingCompanies.isPreQuoteSelected, true) // 본입찰 선정된 업체만 조회 + )) .orderBy(desc(biddingCompanies.finalQuoteAmount)) return vendorsData.map(vendor => ({ @@ -246,27 +261,272 @@ export async function getQuotationVendors(biddingId: number): Promise<QuotationV contactPhone: vendor.contactPhone || '', quotationAmount: Number(vendor.quotationAmount) || 0, currency: vendor.currency, - submissionDate: vendor.submissionDate ? vendor.submissionDate.toISOString().split('T')[0] : '', + submissionDate: vendor.submissionDate ? (vendor.submissionDate instanceof Date ? vendor.submissionDate.toISOString().split('T')[0] : String(vendor.submissionDate).split('T')[0]) : '', isWinner: vendor.isWinner || false, - awardRatio: vendor.awardRatio || 0, + awardRatio: vendor.awardRatio ? Number(vendor.awardRatio) : null, + isBiddingParticipated: vendor.isBiddingParticipated, status: vendor.status as 'pending' | 'submitted' | 'selected' | 'rejected', - // companyConditionResponses에서 입찰 조건들 - paymentTermsResponse: vendor.paymentTermsResponse || '', - taxConditionsResponse: vendor.taxConditionsResponse || '', - incotermsResponse: vendor.incotermsResponse || '', - proposedContractDeliveryDate: vendor.proposedContractDeliveryDate ? (typeof vendor.proposedContractDeliveryDate === 'string' ? vendor.proposedContractDeliveryDate : vendor.proposedContractDeliveryDate.toISOString().split('T')[0]) : undefined, - proposedShippingPort: vendor.proposedShippingPort || '', - proposedDestinationPort: vendor.proposedDestinationPort || '', - priceAdjustmentResponse: vendor.priceAdjustmentResponse || false, - isInitialResponse: vendor.isInitialResponse || false, - sparePartResponse: vendor.sparePartResponse || '', - additionalProposals: vendor.additionalProposals || '', - documents: [] // TODO: 문서 정보 조회 로직 추가 })) } catch (error) { console.error('Failed to get quotation vendors:', error) return [] } + }, + [`quotation-vendors-${biddingId}`], + { + tags: [`bidding-${biddingId}`, 'quotation-vendors'] + } + )() +} + +// 사전견적 데이터 조회 (내정가 산정용) +export async function getPreQuoteData(biddingId: number) { + try { + const preQuotes = await db + .select({ + id: biddingCompanies.id, + companyId: biddingCompanies.companyId, + vendorName: vendors.vendorName, + preQuoteAmount: biddingCompanies.preQuoteAmount, + submittedAt: biddingCompanies.preQuoteSubmittedAt, + }) + .from(biddingCompanies) + .leftJoin(vendors, eq(biddingCompanies.companyId, vendors.id)) + .where(and( + eq(biddingCompanies.biddingId, biddingId), + sql`${biddingCompanies.preQuoteAmount} IS NOT NULL AND ${biddingCompanies.preQuoteAmount} > 0` + )) + .orderBy(biddingCompanies.preQuoteAmount) + + if (preQuotes.length === 0) { + return { + quotes: [], + lowestQuote: null, + highestQuote: null, + averageQuote: null, + quotationCount: 0 + } + } + + const amounts = preQuotes + .map(q => Number(q.preQuoteAmount)) + .filter(amount => !isNaN(amount) && amount > 0) + + console.log('Pre-quote amounts:', amounts) + + if (amounts.length === 0) { + return { + quotes: preQuotes, + lowestQuote: null, + highestQuote: null, + averageQuote: null, + quotationCount: 0 + } + } + + const lowestQuote = Math.min(...amounts) + const highestQuote = Math.max(...amounts) + const averageQuote = amounts.reduce((sum, amount) => sum + amount, 0) / amounts.length + + console.log('Calculated quotes:', { lowestQuote, highestQuote, averageQuote }) + + return { + quotes: preQuotes, + lowestQuote, + highestQuote, + averageQuote, + quotationCount: amounts.length + } + } catch (error) { + console.error('Failed to get pre-quote data:', error) + return { + quotes: [], + lowestQuote: null, + highestQuote: null, + averageQuote: null, + quotationCount: 0 + } + } +} + +// 입찰유형별 내정가 자동 산정 로직 +export async function calculateTargetPrice( + biddingType: string, + budget: number | null, + lowestQuote: number | null, + highestQuote: number | null +): Promise<{ targetPrice: number; criteria: string }> { + const results: Array<{ price: number; description: string }> = [] + + // 입찰유형별 로직 + switch (biddingType) { + case 'equipment': + case 'construction': + case 'service': + case 'lease': + case 'steel_stock': + case 'piping': { + // 예산가 85%, 최저견적가 85% 중 최저가 (직전실적가 95% 제외) + if (budget) { + results.push({ price: budget * 0.85, description: '예산가 85%' }) + } + if (lowestQuote) { + results.push({ price: lowestQuote * 0.85, description: '최저견적가 85%' }) + } + break + } + case 'transport': { + // 예산가 85%, 최저견적가 85% 중 최저가 (직전실적가 95% 제외) + // 만약 예산이 없을 경우 최저견적가의 70% + if (budget) { + results.push({ price: budget * 0.85, description: '예산가 85%' }) + } + if (lowestQuote) { + if (budget) { + results.push({ price: lowestQuote * 0.85, description: '최저견적가 85%' }) + } else { + results.push({ price: lowestQuote * 0.70, description: '최저견적가 70% (예산 없음)' }) + } + } + break + } + case 'waste': { + // 예산가 85%, 최저견적가 70% 중 최저가 (직전실적가 95% 제외) + if (budget) { + results.push({ price: budget * 0.85, description: '예산가 85%' }) + } + if (lowestQuote) { + results.push({ price: lowestQuote * 0.70, description: '최저견적가 70%' }) + } + break + } + case 'sale': { + // 최고견적가 130% (직전실적가 105% 제외) + if (highestQuote) { + results.push({ price: highestQuote * 1.30, description: '최고견적가 130%' }) + } + break + } + default: { + // 기본: 최저견적가 85% + if (lowestQuote) { + results.push({ price: lowestQuote * 0.85, description: '최저견적가 85%' }) + } + break + } + } + + if (results.length === 0) { + return { + targetPrice: 0, + criteria: '산정 가능한 데이터가 없습니다.' + } + } + + // 매각의 경우 최고가, 나머지는 최저가 + const prices = results.map(r => r.price).filter(p => !isNaN(p) && isFinite(p)) + + if (prices.length === 0) { + return { + targetPrice: 0, + criteria: '유효한 가격 데이터가 없습니다.' + } + } + + const targetPrice = biddingType === 'sale' + ? Math.max(...prices) + : Math.min(...prices) + + if (!isFinite(targetPrice) || isNaN(targetPrice)) { + return { + targetPrice: 0, + criteria: '내정가 계산 오류가 발생했습니다.' + } + } + + const selectedResult = results.find(r => r.price === targetPrice) + const criteria = `입찰유형: ${biddingType} - ${selectedResult?.description || ''}로 산정` + + return { + targetPrice: Math.round(targetPrice), + criteria + } +} + +// 내정가 자동 산정 및 업데이트 +export async function calculateAndUpdateTargetPrice( + biddingId: number, + userId: string +) { + try { + // 입찰 정보 조회 + const bidding = await getBiddingById(biddingId) + if (!bidding) { + return { success: false, error: '입찰 정보를 찾을 수 없습니다.' } + } + + // 사전견적 데이터 조회 + const preQuoteData = await getPreQuoteData(biddingId) + + if (preQuoteData.quotationCount === 0) { + return { success: false, error: '사전견적 데이터가 없습니다.' } + } + + // 내정가 산정 + console.log('Bidding data for calculation:', { + biddingType: bidding.biddingType, + budget: bidding.budget, + preQuoteData + }) + + const { targetPrice, criteria } = await calculateTargetPrice( + bidding.biddingType || '', + bidding.budget ? Number(bidding.budget) : null, + preQuoteData.lowestQuote, + preQuoteData.highestQuote + ) + + console.log('Calculated target price:', { targetPrice, criteria }) + + if (!targetPrice || targetPrice <= 0 || isNaN(targetPrice)) { + return { success: false, error: `내정가 산정에 실패했습니다. (계산된 값: ${targetPrice})` } + } + + // 내정가 업데이트 + const updateResult = await updateTargetPrice(biddingId, targetPrice, criteria, userId) + + if (updateResult.success) { + // 내정가 산정 후 입찰 상태를 set_target_price로 변경 (received_quotation 상태에서만) + await db + .update(biddings) + .set({ + status: 'set_target_price', + updatedAt: new Date() + }) + .where(and( + eq(biddings.id, biddingId), + eq(biddings.status, 'received_quotation') + )) + + // 캐시 무효화 + revalidateTag(`bidding-${biddingId}`) + + return { + success: true, + message: '내정가가 자동으로 산정되었습니다.', + data: { + targetPrice, + criteria, + preQuoteData + } + } + } else { + return updateResult + } + } catch (error) { + console.error('Failed to calculate and update target price:', error) + return { success: false, error: '내정가 자동 산정에 실패했습니다.' } + } } // 내정가 수동 업데이트 (실제 저장) @@ -277,15 +537,25 @@ export async function updateTargetPrice( userId: string ) { try { + // 입력값 검증 + if (!targetPrice || targetPrice <= 0 || isNaN(targetPrice)) { + return { success: false, error: `유효하지 않은 내정가입니다: ${targetPrice}` } + } + + console.log('Updating target price:', { biddingId, targetPrice, targetPriceCalculationCriteria }) + await db .update(biddings) .set({ - targetPrice: targetPrice.toString(), + targetPrice: Math.round(targetPrice).toString(), targetPriceCalculationCriteria: targetPriceCalculationCriteria, updatedAt: new Date() }) .where(eq(biddings.id, biddingId)) + // 캐시 무효화 + revalidateTag(`bidding-${biddingId}`) + revalidateTag('quotation-details') revalidatePath(`/evcp/bid/${biddingId}`) return { success: true, message: '내정가가 성공적으로 업데이트되었습니다.' } } catch (error) { @@ -294,6 +564,103 @@ export async function updateTargetPrice( } } +// 본입찰용 업체 수정 (간소화 버전 - 발주비율만 UI에서 수정 가능, 견적금액/통화는 기존값 유지) +export async function updateBiddingDetailVendor( + biddingCompanyId: number, + quotationAmount: number, // 기존값 유지용 + currency: string, // 기존값 유지용 + awardRatio: number, // UI에서 수정 가능 + userId: string +) { + try { + const result = await db.update(biddingCompanies) + .set({ + finalQuoteAmount: quotationAmount.toString(), + awardRatio: awardRatio.toString(), + isWinner: awardRatio > 0, + updatedAt: new Date(), + }) + .where(eq(biddingCompanies.id, biddingCompanyId)) + .returning({ biddingId: biddingCompanies.biddingId }) + + // 캐시 무효화 + if (result.length > 0) { + const biddingId = result[0].biddingId + revalidateTag(`bidding-${biddingId}`) + revalidateTag('quotation-vendors') + revalidateTag('quotation-details') + revalidatePath(`/evcp/bid/${biddingId}`) + } + + return { + success: true, + message: '업체 정보가 성공적으로 수정되었습니다.', + } + } catch (error) { + console.error('Failed to update bidding detail vendor:', error) + return { + success: false, + error: error instanceof Error ? error.message : '업체 정보 수정에 실패했습니다.' + } + } +} + +// 본입찰용 업체 추가 +export async function createBiddingDetailVendor( + biddingId: number, + vendorId: number, + userId: string +) { + try { + const result = await db.transaction(async (tx) => { + // 1. biddingCompanies 레코드 생성 (본입찰 선정 기본값 true) + const biddingCompanyResult = await tx.insert(biddingCompanies).values({ + biddingId: biddingId, + companyId: vendorId, + invitationStatus: 'pending', + isPreQuoteSelected: true, // 본입찰 등록 기본값 + isWinner: false, + createdAt: new Date(), + updatedAt: new Date(), + }).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, + submittedAt: new Date(), + createdAt: new Date(), + updatedAt: new Date(), + }) + + return biddingCompanyId + }) + + // 캐시 무효화 + revalidateTag(`bidding-${biddingId}`) + revalidateTag('quotation-vendors') + revalidateTag('quotation-details') + revalidatePath(`/evcp/bid/${biddingId}`) + + return { + success: true, + message: '업체가 성공적으로 추가되었습니다.', + data: { id: result } + } + } catch (error) { + console.error('Failed to create bidding detail vendor:', error) + return { + success: false, + error: error instanceof Error ? error.message : '업체 추가에 실패했습니다.' + } + } +} + // 협력업체 정보 저장 - biddingCompanies와 companyConditionResponses 테이블에 레코드 생성 export async function createQuotationVendor(input: any, userId: string) { try { @@ -326,7 +693,7 @@ export async function createQuotationVendor(input: any, userId: string) { biddingCompanyId: biddingCompanyId, paymentTermsResponse: input.paymentTermsResponse || '', taxConditionsResponse: input.taxConditionsResponse || '', - proposedContractDeliveryDate: input.proposedContractDeliveryDate ? new Date(input.proposedContractDeliveryDate) : null, + proposedContractDeliveryDate: input.proposedContractDeliveryDate || null, priceAdjustmentResponse: input.priceAdjustmentResponse || false, incotermsResponse: input.incotermsResponse || '', proposedShippingPort: input.proposedShippingPort || '', @@ -342,6 +709,10 @@ export async function createQuotationVendor(input: any, userId: string) { return biddingCompanyId }) + // 캐시 무효화 + revalidateTag(`bidding-${input.biddingId}`) + revalidateTag('quotation-vendors') + revalidateTag('quotation-details') revalidatePath(`/evcp/bid/[id]`) return { success: true, @@ -390,7 +761,7 @@ export async function updateQuotationVendor(id: number, input: any, userId: stri if (input.paymentTermsResponse !== undefined) conditionsUpdateData.paymentTermsResponse = input.paymentTermsResponse if (input.taxConditionsResponse !== undefined) conditionsUpdateData.taxConditionsResponse = input.taxConditionsResponse if (input.incotermsResponse !== undefined) conditionsUpdateData.incotermsResponse = input.incotermsResponse - if (input.proposedContractDeliveryDate !== undefined) conditionsUpdateData.proposedContractDeliveryDate = input.proposedContractDeliveryDate ? new Date(input.proposedContractDeliveryDate) : null + if (input.proposedContractDeliveryDate !== undefined) conditionsUpdateData.proposedContractDeliveryDate = input.proposedContractDeliveryDate || null if (input.proposedShippingPort !== undefined) conditionsUpdateData.proposedShippingPort = input.proposedShippingPort if (input.proposedDestinationPort !== undefined) conditionsUpdateData.proposedDestinationPort = input.proposedDestinationPort if (input.priceAdjustmentResponse !== undefined) conditionsUpdateData.priceAdjustmentResponse = input.priceAdjustmentResponse @@ -406,6 +777,9 @@ export async function updateQuotationVendor(id: number, input: any, userId: stri return true }) + // 캐시 무효화 (모든 입찰 관련 데이터 무효화) + revalidateTag('quotation-vendors') + revalidateTag('quotation-details') revalidatePath(`/evcp/bid/[id]`) return { success: true, @@ -474,8 +848,12 @@ export async function selectWinner(biddingId: number, vendorId: number, awardRat updatedAt: new Date() }) .where(eq(biddings.id, biddingId)) - }) + }) + // 캐시 무효화 + revalidateTag(`bidding-${biddingId}`) + revalidateTag('quotation-vendors') + revalidateTag('quotation-details') revalidatePath(`/evcp/bid/${biddingId}`) return { success: true, message: '낙찰 처리가 완료되었습니다.' } } catch (error) { @@ -487,6 +865,34 @@ export async function selectWinner(biddingId: number, vendorId: number, awardRat // 유찰 처리 export async function markAsDisposal(biddingId: number, userId: string) { try { + // 입찰 정보 조회 + const biddingInfo = await db + .select() + .from(biddings) + .where(eq(biddings.id, biddingId)) + .limit(1) + + if (biddingInfo.length === 0) { + return { success: false, error: '입찰 정보를 찾을 수 없습니다.' } + } + + const bidding = biddingInfo[0] + + // 입찰 참여 업체들 조회 + const participantCompanies = await db + .select({ + companyId: biddingCompanies.companyId, + companyName: vendors.vendorName, + contactEmail: vendors.email + }) + .from(biddingCompanies) + .leftJoin(vendors, eq(biddingCompanies.companyId, vendors.id)) + .where(and( + eq(biddingCompanies.biddingId, biddingId), + eq(biddingCompanies.isBiddingParticipated, true) + )) + + // 입찰 상태를 유찰로 변경 await db .update(biddings) .set({ @@ -495,85 +901,256 @@ export async function markAsDisposal(biddingId: number, userId: string) { }) .where(eq(biddings.id, biddingId)) + // 참여 업체들에게 유찰 안내 메일 발송 + for (const company of participantCompanies) { + if (company.contactEmail) { + try { + await sendEmail({ + to: company.contactEmail, + template: 'bidding-disposal', + context: { + companyName: company.companyName, + biddingNumber: bidding.biddingNumber, + title: bidding.title, + projectName: bidding.projectName, + itemName: bidding.itemName, + biddingType: bidding.biddingType, + processedDate: new Date().toLocaleDateString('ko-KR'), + managerName: bidding.managerName, + managerEmail: bidding.managerEmail, + managerPhone: bidding.managerPhone, + language: 'ko' + } + }) + } catch (emailError) { + console.error(`Failed to send disposal email to ${company.contactEmail}:`, emailError) + } + } + } + + // 캐시 무효화 + revalidateTag(`bidding-${biddingId}`) + revalidateTag('quotation-vendors') + revalidateTag('quotation-details') revalidatePath(`/evcp/bid/${biddingId}`) - return { success: true, message: '유찰 처리가 완료되었습니다.' } + + return { + success: true, + message: `유찰 처리가 완료되었습니다. ${participantCompanies.length}개 업체에 안내 메일을 발송했습니다.` + } } catch (error) { console.error('Failed to mark as disposal:', error) return { success: false, error: '유찰 처리에 실패했습니다.' } } } -// 입찰 등록 (상태 변경) +// 입찰 등록 (사전견적에서 선정된 업체들에게 본입찰 초대 발송) export async function registerBidding(biddingId: number, userId: string) { try { - await db - .update(biddings) - .set({ - status: 'bidding_opened', - updatedAt: new Date() + // 사전견적에서 선정된 업체들 조회 + const selectedCompanies = await db + .select({ + companyId: biddingCompanies.companyId, + companyName: vendors.vendorName, + contactEmail: vendors.email }) + .from(biddingCompanies) + .leftJoin(vendors, eq(biddingCompanies.companyId, vendors.id)) + .where(and( + eq(biddingCompanies.biddingId, biddingId), + eq(biddingCompanies.isPreQuoteSelected, true) + )) + + // 입찰 정보 조회 + const biddingInfo = await db + .select() + .from(biddings) .where(eq(biddings.id, biddingId)) - //todo 입찰 등록하면 bidding_companies invitationStatus를 sent로 변경! - await db - .update(biddingCompanies) + .limit(1) + + if (biddingInfo.length === 0) { + return { success: false, error: '입찰 정보를 찾을 수 없습니다.' } + } + + const bidding = biddingInfo[0] + + await db.transaction(async (tx) => { + // 1. 입찰 상태를 오픈으로 변경 + await tx + .update(biddings) .set({ - invitationStatus: 'sent', + status: 'bidding_opened', updatedAt: new Date() }) - .where(eq(biddingCompanies.biddingId, biddingId)) + .where(eq(biddings.id, biddingId)) + + // 2. 선정된 업체들의 입찰 초대 여부를 true로 변경하고 초대 상태 업데이트 + for (const company of selectedCompanies) { + await tx + .update(biddingCompanies) + .set({ + isBiddingInvited: true, + invitationStatus: 'sent', + updatedAt: new Date() + }) + .where(and( + eq(biddingCompanies.biddingId, biddingId), + eq(biddingCompanies.companyId, company.companyId) + )) + } + }) + + // 3. 선정된 업체들에게 본입찰 초대 메일 발송 + for (const company of selectedCompanies) { + if (company.contactEmail) { + try { + await sendEmail({ + to: company.contactEmail, + template: 'bidding-invitation', // 새로운 본입찰 초대 템플릿 필요 + context: { + companyName: company.companyName, + biddingNumber: bidding.biddingNumber, + title: bidding.title, + projectName: bidding.projectName, + itemName: bidding.itemName, + biddingType: bidding.biddingType, + 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, + language: 'ko' + } + }) + } catch (emailError) { + console.error(`Failed to send bidding invitation email to ${company.contactEmail}:`, emailError) + } + } + } + // 캐시 무효화 + revalidateTag(`bidding-${biddingId}`) + revalidateTag('quotation-vendors') + revalidateTag('quotation-details') revalidatePath(`/evcp/bid/${biddingId}`) - return { success: true, message: '입찰이 성공적으로 등록되었습니다.' } + + return { + success: true, + message: `입찰이 성공적으로 등록되었습니다. ${selectedCompanies.length}개 업체에 초대 메일을 발송했습니다.` + } } catch (error) { console.error('Failed to register bidding:', error) return { success: false, error: '입찰 등록에 실패했습니다.' } } } -// 재입찰 생성 -export async function createRebidding(originalBiddingId: number, userId: string) { +// 재입찰 생성 (기존 입찰의 revision 업데이트 + 메일 발송) +export async function createRebidding(biddingId: number, userId: string) { try { - // 원본 입찰 정보 조회 - const originalBidding = await db + // 기존 입찰 정보 조회 + const bidding = await db .select() .from(biddings) - .where(eq(biddings.id, originalBiddingId)) + .where(eq(biddings.id, biddingId)) .limit(1) - if (originalBidding.length === 0) { - return { success: false, error: '원본 입찰을 찾을 수 없습니다.' } + if (bidding.length === 0) { + return { success: false, error: '입찰을 찾을 수 없습니다.' } } - const original = originalBidding[0] + const originalBidding = bidding[0] + + // 기존 입찰 참여 업체들 조회 + const participantCompanies = await db + .select({ + companyId: biddingCompanies.companyId, + companyName: vendors.vendorName, + contactEmail: vendors.email + }) + .from(biddingCompanies) + .leftJoin(vendors, eq(biddingCompanies.companyId, vendors.id)) + .where(and( + eq(biddingCompanies.biddingId, biddingId), + eq(biddingCompanies.isBiddingParticipated, true) + )) + + // 기존 입찰의 revision 증가 및 상태 변경 + const updatedBidding = await db + .update(biddings) + .set({ + revision: (originalBidding.revision || 0) + 1, + status: 'bidding_opened', // 재입찰 시 다시 오픈 상태로 + updatedAt: new Date() + }) + .where(eq(biddings.id, biddingId)) + .returning({ + id: biddings.id, + biddingNumber: biddings.biddingNumber, + revision: biddings.revision + }) - // 재입찰용 데이터 준비 - const rebiddingData = { - ...original, - id: undefined, - biddingNumber: `${original.biddingNumber}-R${(original.revision || 0) + 1}`, - revision: (original.revision || 0) + 1, - status: 'bidding_generated' as const, - createdAt: new Date(), - updatedAt: new Date() + if (updatedBidding.length === 0) { + return { success: false, error: '재입찰 업데이트에 실패했습니다.' } } - // 새로운 입찰 생성 - const [newBidding] = await db - .insert(biddings) - .values(rebiddingData) - .returning({ id: biddings.id, biddingNumber: biddings.biddingNumber }) + // 참여 업체들의 상태를 대기로 변경 + await db + .update(biddingCompanies) + .set({ + isBiddingParticipated: null, // 대기 상태로 변경 + invitationStatus: 'sent', + updatedAt: new Date() + }) + .where(and( + eq(biddingCompanies.biddingId, biddingId), + eq(biddingCompanies.isBiddingParticipated, true) + )) + // 재입찰 안내 메일 발송 + for (const company of participantCompanies) { + if (company.contactEmail) { + try { + await sendEmail({ + to: company.contactEmail, + template: 'rebidding-invitation', + context: { + companyName: company.companyName, + biddingNumber: updatedBidding[0].biddingNumber, + title: originalBidding.title, + projectName: originalBidding.projectName, + itemName: originalBidding.itemName, + biddingType: originalBidding.biddingType, + revision: updatedBidding[0].revision || 1, + 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, + language: 'ko' + } + }) + } catch (emailError) { + console.error(`Failed to send rebidding email to ${company.contactEmail}:`, emailError) + } + } + } + + // 캐시 무효화 + revalidateTag(`bidding-${biddingId}`) + revalidateTag('quotation-vendors') + revalidateTag('quotation-details') revalidatePath('/evcp/bid') - revalidatePath(`/evcp/bid/${newBidding.id}`) + revalidatePath(`/evcp/bid/${biddingId}`) return { success: true, - message: '재입찰이 성공적으로 생성되었습니다.', - data: newBidding + message: `재입찰이 성공적으로 처리되었습니다. ${participantCompanies.length}개 업체에 안내 메일을 발송했습니다.` } } catch (error) { console.error('Failed to create rebidding:', error) - return { success: false, error: '재입찰 생성에 실패했습니다.' } + return { success: false, error: '재입찰 처리에 실패했습니다.' } } } @@ -603,6 +1180,9 @@ export async function updateVendorSelectionReason(biddingId: number, selectedCom } }) + // 캐시 무효화 + revalidateTag(`bidding-${biddingId}`) + revalidateTag('quotation-vendors') revalidatePath(`/evcp/bid/${biddingId}`) return { success: true, message: '업체 선정 사유가 성공적으로 업데이트되었습니다.' } } catch (error) { @@ -611,6 +1191,257 @@ export async function updateVendorSelectionReason(biddingId: number, selectedCom } } +// 낙찰용 문서 업로드 +export async function uploadAwardDocument(biddingId: number, file: File, userId: string) { + try { + const saveResult = await saveFile({ + file, + directory: `biddings/${biddingId}/award`, + userId: userId + }) + + if (saveResult.success && saveResult.filePath) { + // biddingDocuments 테이블에 저장 + const [document] = await db.insert(biddingDocuments).values({ + biddingId, + fileName: saveResult.fileName || file.name, + originalFileName: file.name, + filePath: saveResult.filePath, + fileSize: file.size, + documentType: 'award', + title: '낙찰 관련 문서', + description: '낙찰 관련 첨부파일', + uploadedBy: userId, + uploadedAt: new Date(), + createdAt: new Date(), + updatedAt: new Date() + }).returning() + + return { + success: true, + message: '파일이 성공적으로 업로드되었습니다.', + document + } + } else { + return { + success: false, + error: saveResult.error || '파일 저장에 실패했습니다.' + } + } + } catch (error) { + console.error('Failed to upload award document:', error) + return { + success: false, + error: '파일 업로드에 실패했습니다.' + } + } +} + +// 낙찰용 문서 목록 조회 +export async function getAwardDocuments(biddingId: number) { + try { + const documents = await db + .select() + .from(biddingDocuments) + .where(and( + eq(biddingDocuments.biddingId, biddingId), + eq(biddingDocuments.documentType, 'other') + )) + .orderBy(desc(biddingDocuments.uploadedAt)) + + return documents + } catch (error) { + console.error('Failed to get award documents:', error) + return [] + } +} + +// 낙찰용 문서 다운로드 +export async function getAwardDocumentForDownload(documentId: number, biddingId: number) { + try { + const documents = await db + .select() + .from(biddingDocuments) + .where(and( + eq(biddingDocuments.id, documentId), + eq(biddingDocuments.biddingId, biddingId), + eq(biddingDocuments.documentType, 'other') + )) + .limit(1) + + if (documents.length === 0) { + return { + success: false, + error: '문서를 찾을 수 없습니다.' + } + } + + return { + success: true, + document: documents[0] + } + } catch (error) { + console.error('Failed to get award document for download:', error) + return { + success: false, + error: '문서 다운로드 준비에 실패했습니다.' + } + } +} + +// 낙찰용 문서 삭제 +export async function deleteAwardDocument(documentId: number, biddingId: number, userId: string) { + try { + // 문서 정보 조회 + const documents = await db + .select() + .from(biddingDocuments) + .where(and( + eq(biddingDocuments.id, documentId), + eq(biddingDocuments.biddingId, biddingId), + eq(biddingDocuments.documentType, 'other'), + eq(biddingDocuments.uploadedBy, userId) + )) + .limit(1) + + if (documents.length === 0) { + return { + success: false, + error: '삭제할 수 있는 문서가 없습니다.' + } + } + + // DB에서 삭제 + await db + .delete(biddingDocuments) + .where(eq(biddingDocuments.id, documentId)) + + // 캐시 무효화 + revalidateTag(`bidding-${biddingId}`) + + return { + success: true, + message: '문서가 성공적으로 삭제되었습니다.' + } + } catch (error) { + console.error('Failed to delete award document:', error) + return { + success: false, + error: '문서 삭제에 실패했습니다.' + } + } +} + +// 낙찰 처리 (발주비율과 함께) +export async function awardBidding(biddingId: number, selectionReason: string, userId: string) { + try { + // 낙찰된 업체들 조회 (isWinner가 true인 업체들) + const awardedCompanies = await db + .select({ + companyId: biddingCompanies.companyId, + finalQuoteAmount: biddingCompanies.finalQuoteAmount, + awardRatio: biddingCompanies.awardRatio + }) + .from(biddingCompanies) + .where(and( + eq(biddingCompanies.biddingId, biddingId), + eq(biddingCompanies.isWinner, true) + )) + + if (awardedCompanies.length === 0) { + return { success: false, error: '낙찰된 업체가 없습니다. 먼저 발주비율을 산정해주세요.' } + } + + // 최종입찰가 계산 (낙찰된 업체의 견적금액 * 발주비율의 합) + let finalBidPrice = 0 + for (const company of awardedCompanies) { + const quoteAmount = parseFloat(company.finalQuoteAmount?.toString() || '0') + const ratio = parseFloat(company.awardRatio?.toString() || '0') / 100 + finalBidPrice += quoteAmount * ratio + } + + await db.transaction(async (tx) => { + // 1. 입찰 상태를 낙찰로 변경하고 최종입찰가 업데이트 + await tx + .update(biddings) + .set({ + status: 'vendor_selected', + finalBidPrice: finalBidPrice.toString(), + updatedAt: new Date() + }) + .where(eq(biddings.id, biddingId)) + + // 2. 선정 사유 저장 (첫 번째 낙찰 업체 기준으로 저장) + const firstAwardedCompany = awardedCompanies[0] + await tx + .insert(vendorSelectionResults) + .values({ + biddingId, + selectedCompanyId: firstAwardedCompany.companyId, + selectionReason, + selectedBy: userId, + selectedAt: new Date(), + createdAt: new Date(), + updatedAt: new Date() + }) + .onConflictDoUpdate({ + target: [vendorSelectionResults.biddingId], + set: { + selectedCompanyId: firstAwardedCompany.companyId, + selectionReason, + selectedBy: userId, + selectedAt: new Date(), + updatedAt: new Date() + } + }) + + }) + + // 캐시 무효화 + revalidateTag(`bidding-${biddingId}`) + revalidateTag('quotation-vendors') + revalidateTag('quotation-details') + revalidatePath(`/evcp/bid/${biddingId}`) + + return { + success: true, + message: `낙찰 처리가 완료되었습니다. 최종입찰가: ${finalBidPrice.toLocaleString()}원` + } + } catch (error) { + console.error('Failed to award bidding:', error) + return { success: false, error: '낙찰 처리에 실패했습니다.' } + } +} + +// 낙찰된 업체 정보 조회 +export async function getAwardedCompanies(biddingId: number) { + try { + const awardedCompanies = await db + .select({ + companyId: biddingCompanies.companyId, + companyName: vendors.vendorName, + finalQuoteAmount: biddingCompanies.finalQuoteAmount, + awardRatio: biddingCompanies.awardRatio + }) + .from(biddingCompanies) + .leftJoin(vendors, eq(biddingCompanies.companyId, vendors.id)) + .where(and( + eq(biddingCompanies.biddingId, biddingId), + eq(biddingCompanies.isWinner, true) + )) + + return awardedCompanies.map(company => ({ + companyId: company.companyId, + companyName: company.companyName, + finalQuoteAmount: parseFloat(company.finalQuoteAmount?.toString() || '0'), + awardRatio: parseFloat(company.awardRatio?.toString() || '0') + })) + } catch (error) { + console.error('Failed to get awarded companies:', error) + return [] + } +} + // PR 품목 정보 업데이트 export async function updatePrItem(prItemId: number, input: Partial<typeof prItemsForBidding.$inferSelect>, userId: string) { try { @@ -622,7 +1453,12 @@ export async function updatePrItem(prItemId: number, input: Partial<typeof prIte }) .where(eq(prItemsForBidding.id, prItemId)) - revalidatePath(`/evcp/bid/${input.biddingId}`) + // 캐시 무효화 + if (input.biddingId) { + revalidateTag(`bidding-${input.biddingId}`) + revalidateTag('pr-items') + revalidatePath(`/evcp/bid/${input.biddingId}`) + } return { success: true, message: '품목 정보가 성공적으로 업데이트되었습니다.' } } catch (error) { console.error('Failed to update PR item:', error) @@ -630,40 +1466,128 @@ export async function updatePrItem(prItemId: number, input: Partial<typeof prIte } } -// 입찰에 협력업체 추가 -export async function addVendorToBidding(biddingId: number, companyId: number, userId: string) { +// 입찰 참여여부 업데이트 +export async function updateBiddingParticipation( + biddingCompanyId: number, + participated: boolean, + userId: string +) { try { - // 이미 추가된 업체인지 확인 - const existing = await db - .select() - .from(biddingCompanies) - .where(and( - eq(biddingCompanies.biddingId, biddingId), - eq(biddingCompanies.companyId, companyId) - )) - .limit(1) + const result = await db.update(biddingCompanies) + .set({ + isBiddingParticipated: participated, + updatedAt: new Date(), + }) + .where(eq(biddingCompanies.id, biddingCompanyId)) + .returning({ biddingId: biddingCompanies.biddingId }) + + // 캐시 무효화 + if (result.length > 0) { + const biddingId = result[0].biddingId + revalidateTag(`bidding-${biddingId}`) + revalidateTag('quotation-vendors') + revalidatePath(`/evcp/bid/${biddingId}`) + } - if (existing.length > 0) { - return { success: false, error: '이미 추가된 협력업체입니다.' } + return { + success: true, + message: `입찰 참여상태가 ${participated ? '응찰' : '미응찰'}로 업데이트되었습니다.`, + } + } catch (error) { + console.error('Failed to update bidding participation:', error) + return { + success: false, + error: error instanceof Error ? error.message : '입찰 참여상태 업데이트에 실패했습니다.' } + } +} - // 새로운 협력업체 추가 - await db - .insert(biddingCompanies) - .values({ - biddingId, - companyId, - invitationStatus: 'pending', - invitedAt: new Date(), - createdAt: new Date(), - updatedAt: new Date() - }) +// ================================================= +// 품목별 견적 관련 함수들 (본입찰용) +// ================================================= - revalidatePath(`/evcp/bid/${biddingId}`) - return { success: true, message: '협력업체가 성공적으로 추가되었습니다.' } +// 품목별 견적 임시 저장 (본입찰용) +export async function saveBiddingDraft( + biddingCompanyId: number, + prItemQuotations: Array<{ + prItemId: number + bidUnitPrice: number + bidAmount: number + proposedDeliveryDate?: string + technicalSpecification?: string + }>, + userId: string +) { + try { + let totalAmount = 0 + + await db.transaction(async (tx) => { + // 품목별 견적 Upsert 방식으로 저장 + for (const item of prItemQuotations) { + // 기존 데이터 확인 + const existingItem = await tx + .select() + .from(companyPrItemBids) + .where( + and( + eq(companyPrItemBids.biddingCompanyId, biddingCompanyId), + eq(companyPrItemBids.prItemId, item.prItemId), + ) + ) + .limit(1) + + const itemData = { + bidUnitPrice: item.bidUnitPrice.toString(), + bidAmount: item.bidAmount.toString(), + proposedDeliveryDate: item.proposedDeliveryDate, + technicalSpecification: item.technicalSpecification, + currency: 'KRW', + updatedAt: new Date() + } + + if (existingItem.length > 0) { + // 업데이트 + await tx + .update(companyPrItemBids) + .set(itemData) + .where( + and( + eq(companyPrItemBids.biddingCompanyId, biddingCompanyId), + eq(companyPrItemBids.prItemId, item.prItemId), + eq(companyPrItemBids.isPreQuote, false) + ) + ) + } else { + // 새로 생성 + await tx.insert(companyPrItemBids) + .values({ + biddingCompanyId, + prItemId: item.prItemId, + isPreQuote: false, // 본입찰 데이터 + createdAt: new Date(), + ...itemData + }) + } + + totalAmount += item.bidAmount + } + }) + + // 캐시 무효화 + revalidateTag(`bidding-${biddingCompanyId}`) + revalidateTag('quotation-vendors') + + return { + success: true, + message: '품목별 견적이 임시 저장되었습니다.', + totalAmount + } } catch (error) { - console.error('Failed to add vendor to bidding:', error) - return { success: false, error: '협력업체 추가에 실패했습니다.' } + console.error('Failed to save bidding draft:', error) + return { + success: false, + error: error instanceof Error ? error.message : '임시 저장에 실패했습니다.' + } } } @@ -671,6 +1595,43 @@ export async function addVendorToBidding(biddingId: number, companyId: number, u // 협력업체 페이지용 함수들 (Partners) // ================================================= +// 협력업체용 입찰 참여여부 업데이트 +export async function updatePartnerBiddingParticipation( + biddingCompanyId: number, + participated: boolean, + userId: string +) { + try { + const result = await db.update(biddingCompanies) + .set({ + isBiddingParticipated: participated, + updatedAt: new Date(), + }) + .where(eq(biddingCompanies.id, biddingCompanyId)) + .returning({ biddingId: biddingCompanies.biddingId }) + + // 캐시 무효화 + if (result.length > 0) { + const biddingId = result[0].biddingId + revalidateTag(`bidding-${biddingId}`) + revalidateTag('quotation-vendors') + revalidateTag(`partners-bidding-${biddingId}`) + revalidatePath(`/partners/bid/${biddingId}`) + } + + return { + success: true, + message: `입찰 참여상태가 ${participated ? '응찰' : '미응찰'}로 업데이트되었습니다.`, + } + } catch (error) { + console.error('Failed to update partner bidding participation:', error) + return { + success: false, + error: error instanceof Error ? error.message : '입찰 참여상태 업데이트에 실패했습니다.' + } + } +} + // 협력업체용 입찰 목록 조회 (bidding_companies 기준) export interface PartnersBiddingListItem { // bidding_companies 정보 @@ -683,6 +1644,7 @@ export interface PartnersBiddingListItem { isWinner: boolean | null isAttendingMeeting: boolean | null isPreQuoteSelected: boolean | null + isBiddingInvited: boolean | null notes: string | null createdAt: Date updatedAt: Date @@ -726,6 +1688,7 @@ export async function getBiddingListForPartners(companyId: number): Promise<Part isWinner: biddingCompanies.isWinner, isAttendingMeeting: biddingCompanies.isAttendingMeeting, isPreQuoteSelected: biddingCompanies.isPreQuoteSelected, + isBiddingInvited: biddingCompanies.isBiddingInvited, notes: biddingCompanies.notes, createdAt: biddingCompanies.createdAt, updatedAt: biddingCompanies.updatedAt, @@ -763,8 +1726,9 @@ export async function getBiddingListForPartners(companyId: number): Promise<Part // 계산된 필드 추가 const resultWithCalculatedFields = result.map(item => ({ ...item, - respondedAt: item.respondedAt ? item.respondedAt.toISOString() : null, + respondedAt: item.respondedAt ? (item.respondedAt instanceof Date ? item.respondedAt.toISOString() : item.respondedAt.toString()) : null, finalQuoteAmount: item.finalQuoteAmount ? Number(item.finalQuoteAmount) : null, // string을 number로 변환 + finalQuoteSubmittedAt: item.finalQuoteSubmittedAt ? (item.finalQuoteSubmittedAt instanceof Date ? item.finalQuoteSubmittedAt.toISOString() : item.finalQuoteSubmittedAt.toString()) : null, responseDeadline: item.submissionStartDate ? new Date(item.submissionStartDate.getTime() - 3 * 24 * 60 * 60 * 1000) // 3일 전 : null, @@ -785,6 +1749,7 @@ export async function getBiddingDetailsForPartners(biddingId: number, companyId: .select({ // 입찰 기본 정보 id: biddings.id, + biddingId: biddings.id, // partners-bidding-detail.tsx에서 필요한 필드 biddingNumber: biddings.biddingNumber, revision: biddings.revision, projectName: biddings.projectName, @@ -825,6 +1790,7 @@ export async function getBiddingDetailsForPartners(biddingId: number, companyId: isWinner: biddingCompanies.isWinner, isAttendingMeeting: biddingCompanies.isAttendingMeeting, isPreQuoteSelected: biddingCompanies.isPreQuoteSelected, + isBiddingParticipated: biddingCompanies.isBiddingParticipated, // 응답한 조건들 (company_condition_responses) - 제시된 조건과 응답 모두 여기서 관리 paymentTermsResponse: companyConditionResponses.paymentTermsResponse, taxConditionsResponse: companyConditionResponses.taxConditionsResponse, @@ -869,6 +1835,13 @@ export async function submitPartnerResponse( sparePartResponse?: string additionalProposals?: string finalQuoteAmount?: number + prItemQuotations?: Array<{ + prItemId: number + bidUnitPrice: number + bidAmount: number + proposedDeliveryDate?: string + technicalSpecification?: string + }> priceAdjustmentForm?: { itemName?: string adjustmentReflectionPoint?: string @@ -891,91 +1864,98 @@ export async function submitPartnerResponse( ) { try { const result = await db.transaction(async (tx) => { - // 1. company_condition_responses 테이블에 응답 저장/업데이트 - const responseData = { - paymentTermsResponse: response.paymentTermsResponse, - taxConditionsResponse: response.taxConditionsResponse, - incotermsResponse: response.incotermsResponse, - proposedContractDeliveryDate: response.proposedContractDeliveryDate ? response.proposedContractDeliveryDate : null, // Date 대신 string 사용 - proposedShippingPort: response.proposedShippingPort, - proposedDestinationPort: response.proposedDestinationPort, - priceAdjustmentResponse: response.priceAdjustmentResponse, - isInitialResponse: response.isInitialResponse, - sparePartResponse: response.sparePartResponse, - additionalProposals: response.additionalProposals, - submittedAt: new Date(), - updatedAt: new Date(), - } - - // 기존 응답이 있는지 확인 - const existingResponse = await tx - .select() - .from(companyConditionResponses) - .where(eq(companyConditionResponses.biddingCompanyId, biddingCompanyId)) - .limit(1) + // 0. 품목별 견적 정보 최종 저장 (본입찰 제출) - Upsert 방식 + if (response.prItemQuotations && response.prItemQuotations.length > 0) { + for (const item of response.prItemQuotations) { + // 기존 데이터 확인 + const existingItem = await tx + .select() + .from(companyPrItemBids) + .where( + and( + eq(companyPrItemBids.biddingCompanyId, biddingCompanyId), + eq(companyPrItemBids.prItemId, item.prItemId), + ) + ) + .limit(1) - let companyConditionResponseId: number + const itemData = { + bidUnitPrice: item.bidUnitPrice.toString(), + bidAmount: item.bidAmount.toString(), + proposedDeliveryDate: item.proposedDeliveryDate || null, + technicalSpecification: item.technicalSpecification || null, + currency: 'KRW', + submittedAt: new Date(), + updatedAt: new Date() + } - if (existingResponse.length > 0) { - // 업데이트 - await tx - .update(companyConditionResponses) - .set(responseData) - .where(eq(companyConditionResponses.biddingCompanyId, biddingCompanyId)) - - companyConditionResponseId = existingResponse[0].id - } else { - // 새로 생성 - const [newResponse] = await tx - .insert(companyConditionResponses) - .values({ - biddingCompanyId, - ...responseData, - }) - .returning({ id: companyConditionResponses.id }) - - companyConditionResponseId = newResponse.id + if (existingItem.length > 0) { + // 업데이트 + await tx + .update(companyPrItemBids) + .set(itemData) + .where( + and( + eq(companyPrItemBids.biddingCompanyId, biddingCompanyId), + eq(companyPrItemBids.prItemId, item.prItemId), + eq(companyPrItemBids.isPreQuote, false) + ) + ) + } else { + // 새로 생성 + await tx.insert(companyPrItemBids) + .values({ + biddingCompanyId, + prItemId: item.prItemId, + isPreQuote: false, // 본입찰 데이터 + createdAt: new Date(), + ...itemData + }) + } + } } - // 3. 연동제 정보 저장 (연동제 적용이 true이고 연동제 정보가 있는 경우) - if (response.priceAdjustmentResponse && response.priceAdjustmentForm) { - const priceAdjustmentData = { - companyConditionResponsesId: companyConditionResponseId, - itemName: response.priceAdjustmentForm.itemName, - adjustmentReflectionPoint: response.priceAdjustmentForm.adjustmentReflectionPoint, - majorApplicableRawMaterial: response.priceAdjustmentForm.majorApplicableRawMaterial, - adjustmentFormula: response.priceAdjustmentForm.adjustmentFormula, - rawMaterialPriceIndex: response.priceAdjustmentForm.rawMaterialPriceIndex, - referenceDate: response.priceAdjustmentForm.referenceDate ? new Date(response.priceAdjustmentForm.referenceDate) : null, - comparisonDate: response.priceAdjustmentForm.comparisonDate ? new Date(response.priceAdjustmentForm.comparisonDate) : null, - adjustmentRatio: response.priceAdjustmentForm.adjustmentRatio, - notes: response.priceAdjustmentForm.notes, - adjustmentConditions: response.priceAdjustmentForm.adjustmentConditions, - majorNonApplicableRawMaterial: response.priceAdjustmentForm.majorNonApplicableRawMaterial, - adjustmentPeriod: response.priceAdjustmentForm.adjustmentPeriod, - contractorWriter: response.priceAdjustmentForm.contractorWriter, - adjustmentDate: response.priceAdjustmentForm.adjustmentDate ? new Date(response.priceAdjustmentForm.adjustmentDate) : null, - nonApplicableReason: response.priceAdjustmentForm.nonApplicableReason, - } - // 기존 연동제 정보가 있는지 확인 - 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) - } - } + // 3. 연동제 정보 저장 (연동제 적용이 true이고 연동제 정보가 있는 경우) + // if (response.priceAdjustmentResponse && response.priceAdjustmentForm) { + // const priceAdjustmentData = { + // companyConditionResponsesId: companyConditionResponseId, + // itemName: response.priceAdjustmentForm.itemName, + // adjustmentReflectionPoint: response.priceAdjustmentForm.adjustmentReflectionPoint, + // majorApplicableRawMaterial: response.priceAdjustmentForm.majorApplicableRawMaterial, + // adjustmentFormula: response.priceAdjustmentForm.adjustmentFormula, + // rawMaterialPriceIndex: response.priceAdjustmentForm.rawMaterialPriceIndex, + // referenceDate: response.priceAdjustmentForm.referenceDate || null, + // comparisonDate: response.priceAdjustmentForm.comparisonDate || null, + // adjustmentRatio: response.priceAdjustmentForm.adjustmentRatio, + // notes: response.priceAdjustmentForm.notes, + // adjustmentConditions: response.priceAdjustmentForm.adjustmentConditions, + // majorNonApplicableRawMaterial: response.priceAdjustmentForm.majorNonApplicableRawMaterial, + // adjustmentPeriod: response.priceAdjustmentForm.adjustmentPeriod, + // contractorWriter: response.priceAdjustmentForm.contractorWriter, + // adjustmentDate: response.priceAdjustmentForm.adjustmentDate || null, + // nonApplicableReason: response.priceAdjustmentForm.nonApplicableReason, + // } + + // // 기존 연동제 정보가 있는지 확인 + // 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) + // } + // } // 2. biddingCompanies 테이블에 견적 금액과 상태 업데이트 const companyUpdateData: any = { @@ -995,9 +1975,38 @@ export async function submitPartnerResponse( .set(companyUpdateData) .where(eq(biddingCompanies.id, biddingCompanyId)) - return true + // biddingId 조회 + const biddingCompanyInfo = await tx + .select({ biddingId: biddingCompanies.biddingId }) + .from(biddingCompanies) + .where(eq(biddingCompanies.id, biddingCompanyId)) + .limit(1) + + const biddingId = biddingCompanyInfo[0]?.biddingId + + // 응찰 제출 시 입찰 상태를 평가중으로 변경 (bidding_opened 상태에서만) + if (biddingId && response.finalQuoteAmount !== undefined) { + await tx + .update(biddings) + .set({ + status: 'evaluation_of_bidding', + updatedAt: new Date() + }) + .where(and( + eq(biddings.id, biddingId), + eq(biddings.status, 'bidding_opened') + )) + } + + return biddingId }) + // 캐시 무효화 + if (result) { + revalidateTag(`bidding-${result}`) + revalidateTag('quotation-vendors') + revalidateTag('quotation-details') + } revalidatePath('/partners/bid/[id]') return { success: true, @@ -1090,7 +2099,7 @@ export async function getSpecificationMeetingForPartners(biddingId: number) { data: { ...bidding[0], documents, - meetingDate: specMeeting[0].meetingDate ? specMeeting[0].meetingDate.toISOString().split('T')[0] : null, + meetingDate: specMeeting[0].meetingDate ? (specMeeting[0].meetingDate instanceof Date ? specMeeting[0].meetingDate.toISOString().split('T')[0] : specMeeting[0].meetingDate.toString().split('T')[0]) : null, meetingTime: specMeeting[0].meetingTime, location: specMeeting[0].location, address: specMeeting[0].address, @@ -1186,6 +2195,10 @@ export async function updatePartnerAttendance( // 메일 발송 실패해도 참석 여부 업데이트는 성공으로 처리 } + // 캐시 무효화 + revalidateTag(`bidding-${biddingInfo[0].biddingId}`) + revalidateTag('quotation-vendors') + return { ...biddingInfo[0], companyName, diff --git a/lib/bidding/detail/table/bidding-award-dialog.tsx b/lib/bidding/detail/table/bidding-award-dialog.tsx new file mode 100644 index 00000000..3ab883f2 --- /dev/null +++ b/lib/bidding/detail/table/bidding-award-dialog.tsx @@ -0,0 +1,259 @@ +'use client' + +import * as React from 'react' +import { useTransition } from 'react' +import { Button } from '@/components/ui/button' +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from '@/components/ui/dialog' +import { Label } from '@/components/ui/label' +import { Textarea } from '@/components/ui/textarea' +import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card' +import { Badge } from '@/components/ui/badge' +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow +} from '@/components/ui/table' +import { Trophy, Building2, Calculator } from 'lucide-react' +import { useToast } from '@/hooks/use-toast' +import { getAwardedCompanies, awardBidding } from '@/lib/bidding/detail/service' +import { AwardSimpleFileUpload } from './components/award-simple-file-upload' + +interface BiddingAwardDialogProps { + biddingId: number + open: boolean + onOpenChange: (open: boolean) => void + onSuccess: () => void +} + +interface AwardedCompany { + companyId: number + companyName: string | null + finalQuoteAmount: number + awardRatio: number +} + +export function BiddingAwardDialog({ + biddingId, + open, + onOpenChange, + onSuccess +}: BiddingAwardDialogProps) { + const { toast } = useToast() + const [isPending, startTransition] = useTransition() + const [selectionReason, setSelectionReason] = React.useState('') + const [awardedCompanies, setAwardedCompanies] = React.useState<AwardedCompany[]>([]) + const [isLoading, setIsLoading] = React.useState(false) + + // 낙찰된 업체 정보 로드 + React.useEffect(() => { + if (open) { + setIsLoading(true) + getAwardedCompanies(biddingId) + .then(companies => { + setAwardedCompanies(companies) + }) + .catch(error => { + console.error('Failed to load awarded companies:', error) + toast({ + title: '오류', + description: '낙찰 업체 정보를 불러오는데 실패했습니다.', + variant: 'destructive', + }) + }) + .finally(() => { + setIsLoading(false) + }) + } + }, [open, biddingId, toast]) + + // 최종입찰가 계산 + const finalBidPrice = React.useMemo(() => { + return awardedCompanies.reduce((sum, company) => { + return sum + (company.finalQuoteAmount * company.awardRatio / 100) + }, 0) + }, [awardedCompanies]) + + const handleSubmit = (e: React.FormEvent) => { + e.preventDefault() + + if (!selectionReason.trim()) { + toast({ + title: '유효성 오류', + description: '낙찰 사유를 입력해주세요.', + variant: 'destructive', + }) + return + } + + if (awardedCompanies.length === 0) { + toast({ + title: '유효성 오류', + description: '낙찰된 업체가 없습니다. 먼저 발주비율을 산정해주세요.', + variant: 'destructive', + }) + return + } + + startTransition(async () => { + const result = await awardBidding(biddingId, selectionReason, 'current-user') + + if (result.success) { + toast({ + title: '성공', + description: result.message, + }) + onSuccess() + onOpenChange(false) + // 폼 초기화 + setSelectionReason('') + } else { + toast({ + title: '오류', + description: result.error, + variant: 'destructive', + }) + } + }) + } + + + return ( + <Dialog open={open} onOpenChange={onOpenChange}> + <DialogContent className="max-w-4xl max-h-[90vh] overflow-y-auto"> + <DialogHeader> + <DialogTitle className="flex items-center gap-2"> + <Trophy className="w-5 h-5 text-yellow-600" /> + 낙찰 처리 + </DialogTitle> + <DialogDescription> + 낙찰된 업체의 발주비율과 선정 사유를 확인하고 낙찰을 완료하세요. + </DialogDescription> + </DialogHeader> + + <form onSubmit={handleSubmit}> + <div className="space-y-6"> + {/* 낙찰 업체 정보 */} + <Card> + <CardHeader> + <CardTitle className="flex items-center gap-2"> + <Building2 className="w-4 h-4" /> + 낙찰 업체 정보 + </CardTitle> + </CardHeader> + <CardContent> + {isLoading ? ( + <div className="text-center py-4"> + <div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary mx-auto"></div> + <p className="mt-2 text-sm text-muted-foreground">낙찰 업체 정보를 불러오는 중...</p> + </div> + ) : awardedCompanies.length > 0 ? ( + <div className="space-y-4"> + <Table> + <TableHeader> + <TableRow> + <TableHead>업체명</TableHead> + <TableHead className="text-right">견적금액</TableHead> + <TableHead className="text-right">발주비율</TableHead> + <TableHead className="text-right">발주금액</TableHead> + </TableRow> + </TableHeader> + <TableBody> + {awardedCompanies.map((company) => ( + <TableRow key={company.companyId}> + <TableCell className="font-medium"> + <div className="flex items-center gap-2"> + <Badge variant="default" className="bg-green-600">낙찰</Badge> + {company.companyName} + </div> + </TableCell> + <TableCell className="text-right"> + {company.finalQuoteAmount.toLocaleString()}원 + </TableCell> + <TableCell className="text-right"> + {company.awardRatio}% + </TableCell> + <TableCell className="text-right font-semibold"> + {(company.finalQuoteAmount * company.awardRatio / 100).toLocaleString()}원 + </TableCell> + </TableRow> + ))} + </TableBody> + </Table> + + {/* 최종입찰가 요약 */} + <div className="flex items-center justify-between p-4 bg-blue-50 border border-blue-200 rounded-lg"> + <div className="flex items-center gap-2"> + <Calculator className="w-5 h-5 text-blue-600" /> + <span className="font-semibold text-blue-800">최종입찰가</span> + </div> + <span className="text-xl font-bold text-blue-800"> + {finalBidPrice.toLocaleString()}원 + </span> + </div> + </div> + ) : ( + <div className="text-center py-8"> + <Trophy className="w-12 h-12 text-gray-400 mx-auto mb-4" /> + <p className="text-gray-500 mb-2">낙찰된 업체가 없습니다</p> + <p className="text-sm text-gray-400"> + 먼저 업체 수정 다이얼로그에서 발주비율을 산정해주세요. + </p> + </div> + )} + </CardContent> + </Card> + + {/* 낙찰 사유 */} + <div className="space-y-2"> + <Label htmlFor="selectionReason"> + 낙찰 사유 <span className="text-red-500">*</span> + </Label> + <Textarea + id="selectionReason" + placeholder="낙찰 사유를 상세히 입력해주세요..." + value={selectionReason} + onChange={(e) => setSelectionReason(e.target.value)} + rows={4} + className="resize-none" + /> + </div> + + {/* 첨부파일 */} + <AwardSimpleFileUpload + biddingId={biddingId} + userId="current-user" + readOnly={false} + /> + </div> + + <DialogFooter className="mt-6"> + <Button + type="button" + variant="outline" + onClick={() => onOpenChange(false)} + disabled={isPending} + > + 취소 + </Button> + <Button + type="submit" + disabled={isPending || awardedCompanies.length === 0} + > + {isPending ? '처리 중...' : '낙찰 완료'} + </Button> + </DialogFooter> + </form> + </DialogContent> + </Dialog> + ) +} diff --git a/lib/bidding/detail/table/bidding-detail-content.tsx b/lib/bidding/detail/table/bidding-detail-content.tsx index 50f0941e..91bea2f4 100644 --- a/lib/bidding/detail/table/bidding-detail-content.tsx +++ b/lib/bidding/detail/table/bidding-detail-content.tsx @@ -7,13 +7,11 @@ import { QuotationDetails, QuotationVendor } from '@/lib/bidding/detail/service' import { BiddingDetailVendorTableContent } from './bidding-detail-vendor-table' import { BiddingDetailItemsDialog } from './bidding-detail-items-dialog' import { BiddingDetailTargetPriceDialog } from './bidding-detail-target-price-dialog' -import { BiddingDetailSelectionReasonDialog } from './bidding-detail-selection-reason-dialog' interface BiddingDetailContentProps { bidding: Bidding quotationDetails: QuotationDetails | null quotationVendors: QuotationVendor[] - biddingCompanies: any[] prItems: any[] } @@ -21,13 +19,13 @@ export function BiddingDetailContent({ bidding, quotationDetails, quotationVendors, - biddingCompanies, prItems }: BiddingDetailContentProps) { const [dialogStates, setDialogStates] = React.useState({ items: false, targetPrice: false, - selectionReason: false + selectionReason: false, + award: false }) const [refreshTrigger, setRefreshTrigger] = React.useState(0) @@ -50,11 +48,11 @@ export function BiddingDetailContent({ biddingId={bidding.id} bidding={bidding} vendors={quotationVendors} - biddingCompanies={biddingCompanies} onRefresh={handleRefresh} onOpenItemsDialog={() => openDialog('items')} onOpenTargetPriceDialog={() => openDialog('targetPrice')} onOpenSelectionReasonDialog={() => openDialog('selectionReason')} + onOpenAwardDialog={() => openDialog('award')} onEdit={undefined} onDelete={undefined} onSelectWinner={undefined} @@ -74,13 +72,6 @@ export function BiddingDetailContent({ bidding={bidding} onSuccess={handleRefresh} /> - - <BiddingDetailSelectionReasonDialog - open={dialogStates.selectionReason} - onOpenChange={(open) => closeDialog('selectionReason')} - bidding={bidding} - onSuccess={handleRefresh} - /> </div> ) } diff --git a/lib/bidding/detail/table/bidding-detail-selection-reason-dialog.tsx b/lib/bidding/detail/table/bidding-detail-selection-reason-dialog.tsx deleted file mode 100644 index 0e7ca364..00000000 --- a/lib/bidding/detail/table/bidding-detail-selection-reason-dialog.tsx +++ /dev/null @@ -1,167 +0,0 @@ -'use client' - -import * as React from 'react' -import { Bidding } from '@/db/schema' -import { updateVendorSelectionReason } from '@/lib/bidding/detail/service' -import { - Dialog, - DialogContent, - DialogDescription, - DialogFooter, - DialogHeader, - DialogTitle, -} from '@/components/ui/dialog' -import { Button } from '@/components/ui/button' -import { Textarea } from '@/components/ui/textarea' -import { Label } from '@/components/ui/label' -import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select' -import { useToast } from '@/hooks/use-toast' -import { useTransition } from 'react' - -interface BiddingDetailSelectionReasonDialogProps { - open: boolean - onOpenChange: (open: boolean) => void - bidding: Bidding - onSuccess: () => void -} - -export function BiddingDetailSelectionReasonDialog({ - open, - onOpenChange, - bidding, - onSuccess -}: BiddingDetailSelectionReasonDialogProps) { - const { toast } = useToast() - const [isPending, startTransition] = useTransition() - const [selectedCompanyId, setSelectedCompanyId] = React.useState<number | null>(null) - const [selectionReason, setSelectionReason] = React.useState('') - - // 낙찰된 업체 정보 조회 (실제로는 bidding_companies에서 isWinner가 true인 업체를 조회해야 함) - React.useEffect(() => { - if (open) { - // TODO: 실제로는 낙찰된 업체 정보를 조회하여 selectedCompanyId를 설정 - setSelectedCompanyId(null) - setSelectionReason('') - } - }, [open]) - - const handleSave = () => { - if (!selectedCompanyId) { - toast({ - title: '유효성 오류', - description: '선정된 업체를 선택해주세요.', - variant: 'destructive', - }) - return - } - - if (!selectionReason.trim()) { - toast({ - title: '유효성 오류', - description: '선정 사유를 입력해주세요.', - variant: 'destructive', - }) - return - } - - startTransition(async () => { - const result = await updateVendorSelectionReason( - bidding.id, - selectedCompanyId, - selectionReason, - 'current-user' // TODO: 실제 사용자 ID - ) - - if (result.success) { - toast({ - title: '성공', - description: result.message, - }) - onSuccess() - onOpenChange(false) - } else { - toast({ - title: '오류', - description: result.error, - variant: 'destructive', - }) - } - }) - } - - return ( - <Dialog open={open} onOpenChange={onOpenChange}> - <DialogContent className="sm:max-w-[600px]"> - <DialogHeader> - <DialogTitle>업체 선정 사유</DialogTitle> - <DialogDescription> - 입찰번호: {bidding.biddingNumber} - 낙찰 업체 선정 사유 입력 - </DialogDescription> - </DialogHeader> - - <div className="space-y-6"> - {/* 낙찰 정보 */} - <div className="space-y-4"> - <div className="grid grid-cols-2 gap-4"> - <div> - <Label htmlFor="biddingNumber">입찰번호</Label> - <div className="text-sm font-mono mt-1 p-2 bg-muted rounded"> - {bidding.biddingNumber} - </div> - </div> - <div> - <Label htmlFor="projectName">프로젝트명</Label> - <div className="text-sm mt-1 p-2 bg-muted rounded"> - {bidding.projectName || '-'} - </div> - </div> - </div> - </div> - - {/* 선정 업체 선택 */} - <div className="space-y-2"> - <Label htmlFor="selectedCompany">선정된 업체</Label> - <Select - value={selectedCompanyId?.toString() || ''} - onValueChange={(value) => setSelectedCompanyId(Number(value))} - > - <SelectTrigger> - <SelectValue placeholder="선정된 업체를 선택하세요" /> - </SelectTrigger> - <SelectContent> - {/* TODO: 실제로는 낙찰된 업체 목록을 조회하여 표시 */} - <SelectItem value="1">업체 A</SelectItem> - <SelectItem value="2">업체 B</SelectItem> - <SelectItem value="3">업체 C</SelectItem> - </SelectContent> - </Select> - </div> - - {/* 선정 사유 입력 */} - <div className="space-y-2"> - <Label htmlFor="selectionReason">선정 사유</Label> - <Textarea - id="selectionReason" - value={selectionReason} - onChange={(e) => setSelectionReason(e.target.value)} - placeholder="업체 선정 사유를 상세히 입력해주세요." - rows={6} - /> - <div className="text-sm text-muted-foreground"> - 선정 사유는 추후 검토 및 감사에 활용됩니다. 구체적인 선정 기준과 이유를 명확히 기재해주세요. - </div> - </div> - </div> - - <DialogFooter> - <Button variant="outline" onClick={() => onOpenChange(false)}> - 취소 - </Button> - <Button onClick={handleSave} disabled={isPending}> - 저장 - </Button> - </DialogFooter> - </DialogContent> - </Dialog> - ) -} diff --git a/lib/bidding/detail/table/bidding-detail-target-price-dialog.tsx b/lib/bidding/detail/table/bidding-detail-target-price-dialog.tsx index b9dd44dd..e2cf964b 100644 --- a/lib/bidding/detail/table/bidding-detail-target-price-dialog.tsx +++ b/lib/bidding/detail/table/bidding-detail-target-price-dialog.tsx @@ -2,7 +2,7 @@ import * as React from 'react' import { Bidding } from '@/db/schema' -import { QuotationDetails, updateTargetPrice } from '@/lib/bidding/detail/service' +import { QuotationDetails, updateTargetPrice, calculateAndUpdateTargetPrice, getPreQuoteData } from '@/lib/bidding/detail/service' import { Dialog, DialogContent, @@ -49,15 +49,69 @@ export function BiddingDetailTargetPriceDialog({ const [calculationCriteria, setCalculationCriteria] = React.useState( (bidding as any).targetPriceCalculationCriteria || '' ) + const [preQuoteData, setPreQuoteData] = React.useState<any>(null) + const [isAutoCalculating, setIsAutoCalculating] = React.useState(false) - // Dialog가 열릴 때 상태 초기화 + // Dialog가 열릴 때 상태 초기화 및 사전견적 데이터 로드 React.useEffect(() => { if (open) { setTargetPrice(bidding.targetPrice ? Number(bidding.targetPrice) : 0) setCalculationCriteria((bidding as any).targetPriceCalculationCriteria || '') + + // 사전견적 데이터 로드 + const loadPreQuoteData = async () => { + try { + const data = await getPreQuoteData(bidding.id) + setPreQuoteData(data) + } catch (error) { + console.error('Failed to load pre-quote data:', error) + } + } + loadPreQuoteData() } }, [open, bidding]) + // 자동 산정 함수 + const handleAutoCalculate = () => { + setIsAutoCalculating(true) + + startTransition(async () => { + try { + const result = await calculateAndUpdateTargetPrice( + bidding.id, + 'current-user' // TODO: 실제 사용자 ID + ) + + if (result.success && result.data) { + setTargetPrice(result.data.targetPrice) + setCalculationCriteria(result.data.criteria) + setPreQuoteData(result.data.preQuoteData) + + toast({ + title: '성공', + description: result.message, + }) + + onSuccess() + } else { + toast({ + title: '오류', + description: result.error, + variant: 'destructive', + }) + } + } catch (error) { + toast({ + title: '오류', + description: '내정가 자동 산정에 실패했습니다.', + variant: 'destructive', + }) + } finally { + setIsAutoCalculating(false) + } + }) + } + const handleSave = () => { // 필수값 검증 if (targetPrice <= 0) { @@ -121,6 +175,42 @@ export function BiddingDetailTargetPriceDialog({ </DialogHeader> <div className="space-y-4"> + {/* 사전견적 리스트 */} + {preQuoteData?.quotes && preQuoteData.quotes.length > 0 && ( + <div className="mb-4"> + <h4 className="text-sm font-medium mb-2">사전견적 현황</h4> + <div className="border rounded-lg"> + <Table> + <TableHeader> + <TableRow> + <TableHead>업체명</TableHead> + <TableHead className="text-right">사전견적가</TableHead> + <TableHead className="text-right">제출일</TableHead> + </TableRow> + </TableHeader> + <TableBody> + {preQuoteData.quotes.map((quote: any) => ( + <TableRow key={quote.id}> + <TableCell className="font-medium"> + {quote.vendorName || `업체 ${quote.companyId}`} + </TableCell> + <TableCell className="text-right font-mono"> + {formatCurrency(Number(quote.preQuoteAmount))} + </TableCell> + <TableCell className="text-right text-sm text-muted-foreground"> + {quote.submittedAt + ? new Date(quote.submittedAt).toLocaleDateString('ko-KR') + : '-' + } + </TableCell> + </TableRow> + ))} + </TableBody> + </Table> + </div> + </div> + )} + <Table> <TableHeader> <TableRow> @@ -129,29 +219,43 @@ export function BiddingDetailTargetPriceDialog({ </TableRow> </TableHeader> <TableBody> - {/* 견적 통계 정보 */} - <TableRow> - <TableCell className="font-medium">예상액</TableCell> - <TableCell className="font-semibold"> - {quotationDetails?.estimatedPrice ? formatCurrency(quotationDetails.estimatedPrice) : '-'} - </TableCell> - </TableRow> - <TableRow> - <TableCell className="font-medium">최저견적가</TableCell> - <TableCell className="font-semibold text-green-600"> - {quotationDetails?.lowestQuote ? formatCurrency(quotationDetails.lowestQuote) : '-'} - </TableCell> - </TableRow> + {/* 사전견적 통계 정보 */} <TableRow> - <TableCell className="font-medium">평균견적가</TableCell> + <TableCell className="font-medium">사전견적 수</TableCell> <TableCell className="font-semibold"> - {quotationDetails?.averageQuote ? formatCurrency(quotationDetails.averageQuote) : '-'} + {preQuoteData?.quotationCount || 0}개 </TableCell> </TableRow> + {preQuoteData?.lowestQuote && ( + <TableRow> + <TableCell className="font-medium">최저 사전견적가</TableCell> + <TableCell className="font-semibold text-green-600"> + {formatCurrency(preQuoteData.lowestQuote)} + </TableCell> + </TableRow> + )} + {preQuoteData?.highestQuote && ( + <TableRow> + <TableCell className="font-medium">최고 사전견적가</TableCell> + <TableCell className="font-semibold text-blue-600"> + {formatCurrency(preQuoteData.highestQuote)} + </TableCell> + </TableRow> + )} + {preQuoteData?.averageQuote && ( + <TableRow> + <TableCell className="font-medium">평균 사전견적가</TableCell> + <TableCell className="font-semibold"> + {formatCurrency(preQuoteData.averageQuote)} + </TableCell> + </TableRow> + )} + + {/* 입찰 유형 */} <TableRow> - <TableCell className="font-medium">견적 수</TableCell> + <TableCell className="font-medium">입찰 유형</TableCell> <TableCell className="font-semibold"> - {quotationDetails?.quotationCount || 0}개 + {bidding.biddingType || '-'} </TableCell> </TableRow> @@ -184,17 +288,33 @@ export function BiddingDetailTargetPriceDialog({ </TableCell> <TableCell> <div className="space-y-2"> - <Input - id="targetPrice" - type="number" - value={targetPrice} - onChange={(e) => setTargetPrice(Number(e.target.value))} - placeholder="내정가를 입력하세요" - className="w-full" - /> + <div className="flex gap-2"> + <Input + id="targetPrice" + type="number" + value={targetPrice} + onChange={(e) => setTargetPrice(Number(e.target.value))} + placeholder="내정가를 입력하세요" + className="flex-1" + /> + <Button + type="button" + variant="outline" + onClick={handleAutoCalculate} + disabled={isAutoCalculating || isPending || !preQuoteData?.quotationCount} + className="whitespace-nowrap" + > + {isAutoCalculating ? '산정 중...' : '자동 산정'} + </Button> + </div> <div className="text-sm text-muted-foreground"> {targetPrice > 0 ? formatCurrency(targetPrice) : ''} </div> + {preQuoteData?.quotationCount === 0 && ( + <div className="text-xs text-orange-600"> + 사전견적 데이터가 없어 자동 산정이 불가능합니다. + </div> + )} </div> </TableCell> </TableRow> @@ -211,7 +331,7 @@ export function BiddingDetailTargetPriceDialog({ id="calculationCriteria" value={calculationCriteria} onChange={(e) => setCalculationCriteria(e.target.value)} - placeholder="내정가 산정 기준을 자세히 입력해주세요. (예: 최저견적가 대비 10% 상향 조정, 시장 평균가 고려 등)" + placeholder="내정가 산정 기준을 자세히 입력해주세요. 자동 산정 시 입찰유형에 따른 기준이 자동 설정됩니다." className="w-full min-h-[100px]" rows={4} /> @@ -228,7 +348,7 @@ export function BiddingDetailTargetPriceDialog({ <Button variant="outline" onClick={() => onOpenChange(false)}> 취소 </Button> - <Button onClick={handleSave} disabled={isPending}> + <Button onClick={handleSave} disabled={isPending || isAutoCalculating}> 저장 </Button> </DialogFooter> diff --git a/lib/bidding/detail/table/bidding-detail-vendor-columns.tsx b/lib/bidding/detail/table/bidding-detail-vendor-columns.tsx index 6f02497f..bb1d2c62 100644 --- a/lib/bidding/detail/table/bidding-detail-vendor-columns.tsx +++ b/lib/bidding/detail/table/bidding-detail-vendor-columns.tsx @@ -23,13 +23,17 @@ interface GetVendorColumnsProps { onDelete: (vendor: QuotationVendor) => void onSelectWinner: (vendor: QuotationVendor) => void onViewPriceAdjustment?: (vendor: QuotationVendor) => void + onSendBidding?: (vendor: QuotationVendor) => void + onUpdateParticipation?: (vendor: QuotationVendor, participated: boolean) => void } export function getBiddingDetailVendorColumns({ onEdit, onDelete, onSelectWinner, - onViewPriceAdjustment + onViewPriceAdjustment, + onSendBidding, + onUpdateParticipation }: GetVendorColumnsProps): ColumnDef<QuotationVendor>[] { return [ { @@ -66,13 +70,6 @@ export function getBiddingDetailVendorColumns({ ), }, { - accessorKey: 'contactPerson', - header: '담당자', - cell: ({ row }) => ( - <div className="text-sm">{row.original.contactPerson || '-'}</div> - ), - }, - { accessorKey: 'quotationAmount', header: '견적금액', cell: ({ row }) => ( @@ -82,15 +79,45 @@ export function getBiddingDetailVendorColumns({ ), }, { + accessorKey: 'biddingResult', + header: '입찰결과', + cell: ({ row }) => { + const isWinner = row.original.isWinner + if (isWinner === null || isWinner === undefined) { + return <div>-</div> + } + return ( + <Badge variant={isWinner ? 'default' : 'secondary'} className={isWinner ? 'bg-green-600' : ''}> + {isWinner ? '낙찰' : '탈락'} + </Badge> + ) + }, + }, + { accessorKey: 'awardRatio', header: '발주비율', cell: ({ row }) => ( <div className="text-right"> - {row.original.awardRatio ? `${row.original.awardRatio}%` : '-'} + {row.original.awardRatio !== null ? `${row.original.awardRatio}%` : '-'} </div> ), }, { + accessorKey: 'isBiddingParticipated', + header: '입찰참여', + cell: ({ row }) => { + const participated = row.original.isBiddingParticipated + if (participated === null) { + return <Badge variant="outline">대기</Badge> + } + return ( + <Badge variant={participated ? 'default' : 'destructive'}> + {participated ? '응찰' : '미응찰'} + </Badge> + ) + }, + }, + { accessorKey: 'status', header: '상태', cell: ({ row }) => { @@ -116,103 +143,6 @@ export function getBiddingDetailVendorColumns({ ), }, { - 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 }) => ( - <Badge variant={row.original.isInitialResponse ? 'default' : 'secondary'}> - {row.original.isInitialResponse ? 'Y' : 'N'} - </Badge> - ), - }, - { - accessorKey: 'priceAdjustmentResponse', - header: '연동제', - cell: ({ row }) => { - const hasPriceAdjustment = row.original.priceAdjustmentResponse - 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> - ), - }, - { id: 'actions', header: '작업', cell: ({ row }) => { @@ -229,21 +159,42 @@ export function getBiddingDetailVendorColumns({ <DropdownMenuContent align="end"> <DropdownMenuLabel>작업</DropdownMenuLabel> <DropdownMenuItem onClick={() => onEdit(vendor)}> - <Edit className="mr-2 h-4 w-4" /> - 수정 + 발주비율 산정 </DropdownMenuItem> {vendor.status !== 'selected' && ( <DropdownMenuItem onClick={() => onSelectWinner(vendor)}> - <Trophy className="mr-2 h-4 w-4" /> 낙찰 선정 </DropdownMenuItem> )} + + {/* 입찰 참여여부 관리 */} + {vendor.isBiddingParticipated === null && onUpdateParticipation && ( + <> + <DropdownMenuSeparator /> + <DropdownMenuItem onClick={() => onUpdateParticipation(vendor, true)}> + 응찰 설정 + </DropdownMenuItem> + <DropdownMenuItem onClick={() => onUpdateParticipation(vendor, false)}> + 미응찰 설정 + </DropdownMenuItem> + </> + )} + + {/* 입찰 보내기 (응찰한 업체만) */} + {vendor.isBiddingParticipated === true && onSendBidding && ( + <> + <DropdownMenuSeparator /> + <DropdownMenuItem onClick={() => onSendBidding(vendor)}> + 입찰 보내기 + </DropdownMenuItem> + </> + )} + <DropdownMenuSeparator /> <DropdownMenuItem onClick={() => onDelete(vendor)} className="text-destructive" > - <Trash2 className="mr-2 h-4 w-4" /> 삭제 </DropdownMenuItem> </DropdownMenuContent> diff --git a/lib/bidding/detail/table/bidding-detail-vendor-create-dialog.tsx b/lib/bidding/detail/table/bidding-detail-vendor-create-dialog.tsx index bd0f3684..75b1f67b 100644 --- a/lib/bidding/detail/table/bidding-detail-vendor-create-dialog.tsx +++ b/lib/bidding/detail/table/bidding-detail-vendor-create-dialog.tsx @@ -35,8 +35,7 @@ import { } from '@/components/ui/popover' import { Check, ChevronsUpDown, Search } from 'lucide-react' import { cn } from '@/lib/utils' -import { createQuotationVendor } from '@/lib/bidding/detail/service' -import { createQuotationVendorSchema } from '@/lib/bidding/validation' +import { createBiddingDetailVendor } from '@/lib/bidding/detail/service' import { searchVendors } from '@/lib/vendors/service' import { useToast } from '@/hooks/use-toast' import { useTransition } from 'react' @@ -70,22 +69,9 @@ export function BiddingDetailVendorCreateDialog({ const [vendorSearchOpen, setVendorSearchOpen] = React.useState(false) const [vendorSearchValue, setVendorSearchValue] = React.useState('') - // 폼 상태 + // 폼 상태 (간소화 - 필수 항목만) const [formData, setFormData] = React.useState({ - quotationAmount: 0, - currency: 'KRW', - awardRatio: 0, - status: 'pending' as const, - // 입찰 조건 (companyConditionResponses 기반) - paymentTermsResponse: '', - taxConditionsResponse: '', - proposedContractDeliveryDate: '', - priceAdjustmentResponse: false, - incotermsResponse: '', - proposedShippingPort: '', - proposedDestinationPort: '', - sparePartResponse: '', - additionalProposals: '', + awardRatio: 100, // 기본 100% }) // Vendor 검색 @@ -125,28 +111,13 @@ export function BiddingDetailVendorCreateDialog({ return } - const result = createQuotationVendorSchema.safeParse({ - biddingId, - vendorId: selectedVendor.id, - vendorName: selectedVendor.vendorName, - vendorCode: selectedVendor.vendorCode, - contactPerson: '', - contactEmail: '', - contactPhone: '', - ...formData, - }) - - if (!result.success) { - toast({ - title: '유효성 오류', - description: result.error.issues[0]?.message || '입력값을 확인해주세요.', - variant: 'destructive', - }) - return - } startTransition(async () => { - const response = await createQuotationVendor(result.data, 'current-user') + const response = await createBiddingDetailVendor( + biddingId, + selectedVendor.id, + 'current-user' + ) if (response.success) { toast({ @@ -170,20 +141,7 @@ export function BiddingDetailVendorCreateDialog({ setSelectedVendor(null) setVendorSearchValue('') setFormData({ - quotationAmount: 0, - currency: 'KRW', - awardRatio: 0, - status: 'pending', - // 입찰 조건 초기화 - paymentTermsResponse: '', - taxConditionsResponse: '', - proposedContractDeliveryDate: '', - priceAdjustmentResponse: false, - incotermsResponse: '', - proposedShippingPort: '', - proposedDestinationPort: '', - sparePartResponse: '', - additionalProposals: '', + awardRatio: 100, // 기본 100% }) } @@ -250,167 +208,6 @@ export function BiddingDetailVendorCreateDialog({ </PopoverContent> </Popover> </div> - - {/* 견적 정보 입력 */} - <div className="grid grid-cols-2 gap-4"> - <div className="space-y-2"> - <Label htmlFor="quotationAmount">견적금액</Label> - <Input - id="quotationAmount" - type="number" - value={formData.quotationAmount} - onChange={(e) => setFormData({ ...formData, quotationAmount: Number(e.target.value) })} - placeholder="견적금액을 입력하세요" - /> - </div> - <div className="space-y-2"> - <Label htmlFor="currency">통화</Label> - <Select value={formData.currency} onValueChange={(value) => setFormData({ ...formData, currency: value })}> - <SelectTrigger> - <SelectValue /> - </SelectTrigger> - <SelectContent> - <SelectItem value="KRW">KRW</SelectItem> - <SelectItem value="USD">USD</SelectItem> - <SelectItem value="EUR">EUR</SelectItem> - </SelectContent> - </Select> - </div> - </div> - - <div className="grid grid-cols-2 gap-4"> - <div className="space-y-2"> - <Label htmlFor="awardRatio">발주비율 (%)</Label> - <Input - id="awardRatio" - type="number" - min="0" - max="100" - value={formData.awardRatio} - onChange={(e) => setFormData({ ...formData, awardRatio: Number(e.target.value) })} - placeholder="발주비율을 입력하세요" - /> - </div> - <div className="space-y-2"> - <Label htmlFor="status">상태</Label> - <Select value={formData.status} onValueChange={(value: any) => setFormData({ ...formData, status: value })}> - <SelectTrigger> - <SelectValue /> - </SelectTrigger> - <SelectContent> - <SelectItem value="pending">대기</SelectItem> - <SelectItem value="submitted">제출</SelectItem> - <SelectItem value="selected">선정</SelectItem> - <SelectItem value="rejected">거절</SelectItem> - </SelectContent> - </Select> - </div> - </div> - - - - {/* 입찰 조건 섹션 */} - <div className="col-span-2 pt-4 border-t"> - <h3 className="text-lg font-medium mb-4">입찰 조건 설정</h3> - - <div className="grid grid-cols-2 gap-4"> - <div className="space-y-2"> - <Label htmlFor="paymentTermsResponse">지급조건</Label> - <Input - id="paymentTermsResponse" - value={formData.paymentTermsResponse} - onChange={(e) => setFormData({ ...formData, paymentTermsResponse: e.target.value })} - placeholder="지급조건을 입력하세요" - /> - </div> - - <div className="space-y-2"> - <Label htmlFor="taxConditionsResponse">세금조건</Label> - <Input - id="taxConditionsResponse" - value={formData.taxConditionsResponse} - onChange={(e) => setFormData({ ...formData, taxConditionsResponse: e.target.value })} - placeholder="세금조건을 입력하세요" - /> - </div> - </div> - - <div className="grid grid-cols-2 gap-4 mt-4"> - <div className="space-y-2"> - <Label htmlFor="incotermsResponse">운송조건 (Incoterms)</Label> - <Input - id="incotermsResponse" - value={formData.incotermsResponse} - onChange={(e) => setFormData({ ...formData, incotermsResponse: e.target.value })} - placeholder="운송조건을 입력하세요" - /> - </div> - - <div className="space-y-2"> - <Label htmlFor="proposedContractDeliveryDate">제안 계약납기일</Label> - <Input - id="proposedContractDeliveryDate" - type="date" - value={formData.proposedContractDeliveryDate} - onChange={(e) => setFormData({ ...formData, proposedContractDeliveryDate: e.target.value })} - /> - </div> - </div> - - <div className="grid grid-cols-2 gap-4 mt-4"> - <div className="space-y-2"> - <Label htmlFor="proposedShippingPort">제안 선적지</Label> - <Input - id="proposedShippingPort" - value={formData.proposedShippingPort} - onChange={(e) => setFormData({ ...formData, proposedShippingPort: e.target.value })} - placeholder="선적지를 입력하세요" - /> - </div> - - <div className="space-y-2"> - <Label htmlFor="proposedDestinationPort">제안 도착지</Label> - <Input - id="proposedDestinationPort" - value={formData.proposedDestinationPort} - onChange={(e) => setFormData({ ...formData, proposedDestinationPort: e.target.value })} - placeholder="도착지를 입력하세요" - /> - </div> - </div> - - <div className="space-y-2 mt-4"> - <Label htmlFor="sparePartResponse">스페어파트 응답</Label> - <Input - id="sparePartResponse" - value={formData.sparePartResponse} - onChange={(e) => setFormData({ ...formData, sparePartResponse: e.target.value })} - placeholder="스페어파트 관련 응답을 입력하세요" - /> - </div> - - <div className="space-y-2 mt-4"> - <Label htmlFor="additionalProposals">추가 제안사항</Label> - <Textarea - id="additionalProposals" - value={formData.additionalProposals} - onChange={(e) => setFormData({ ...formData, additionalProposals: e.target.value })} - placeholder="추가 제안사항을 입력하세요" - rows={3} - /> - </div> - - <div className="flex items-center space-x-2 mt-4"> - <Checkbox - id="priceAdjustmentResponse" - checked={formData.priceAdjustmentResponse} - onCheckedChange={(checked) => - setFormData({ ...formData, priceAdjustmentResponse: !!checked }) - } - /> - <Label htmlFor="priceAdjustmentResponse">연동제 적용</Label> - </div> - </div> </div> <DialogFooter> <Button variant="outline" onClick={() => onOpenChange(false)}> diff --git a/lib/bidding/detail/table/bidding-detail-vendor-edit-dialog.tsx b/lib/bidding/detail/table/bidding-detail-vendor-edit-dialog.tsx index 75f53503..b10212ab 100644 --- a/lib/bidding/detail/table/bidding-detail-vendor-edit-dialog.tsx +++ b/lib/bidding/detail/table/bidding-detail-vendor-edit-dialog.tsx @@ -21,8 +21,7 @@ import { SelectTrigger, SelectValue, } from '@/components/ui/select' -import { updateQuotationVendor } from '@/lib/bidding/detail/service' -import { updateQuotationVendorSchema } from '@/lib/bidding/validation' +import { updateBiddingDetailVendor } from '@/lib/bidding/detail/service' import { QuotationVendor } from '@/lib/bidding/detail/service' import { useToast } from '@/hooks/use-toast' import { useTransition } from 'react' @@ -43,52 +42,16 @@ export function BiddingDetailVendorEditDialog({ const { toast } = useToast() const [isPending, startTransition] = useTransition() - // 폼 상태 + // 폼 상태 (간소화 - 수정 가능한 필드만) const [formData, setFormData] = React.useState({ - vendorName: '', - vendorCode: '', - contactPerson: '', - contactEmail: '', - contactPhone: '', - quotationAmount: 0, - currency: 'KRW', awardRatio: 0, - status: 'pending' as const, - // 입찰 조건 (companyConditionResponses 기반) - paymentTermsResponse: '', - taxConditionsResponse: '', - proposedContractDeliveryDate: '', - priceAdjustmentResponse: false, - incotermsResponse: '', - proposedShippingPort: '', - proposedDestinationPort: '', - sparePartResponse: '', - additionalProposals: '', }) // vendor가 변경되면 폼 데이터 업데이트 React.useEffect(() => { if (vendor) { setFormData({ - vendorName: vendor.vendorName, - vendorCode: vendor.vendorCode, - contactPerson: vendor.contactPerson || '', - contactEmail: vendor.contactEmail || '', - contactPhone: vendor.contactPhone || '', - quotationAmount: vendor.quotationAmount, - currency: vendor.currency, awardRatio: vendor.awardRatio || 0, - status: vendor.status, - // 입찰 조건 데이터 (vendor에서 가져오거나 기본값) - paymentTermsResponse: '', - taxConditionsResponse: '', - proposedContractDeliveryDate: '', - priceAdjustmentResponse: false, - incotermsResponse: '', - proposedShippingPort: '', - proposedDestinationPort: '', - sparePartResponse: '', - additionalProposals: '', }) } }, [vendor]) @@ -96,22 +59,15 @@ export function BiddingDetailVendorEditDialog({ const handleEdit = () => { if (!vendor) return - const result = updateQuotationVendorSchema.safeParse({ - id: vendor.id, - ...formData, - }) - - if (!result.success) { - toast({ - title: '유효성 오류', - description: result.error.issues[0]?.message || '입력값을 확인해주세요.', - variant: 'destructive', - }) - return - } startTransition(async () => { - const response = await updateQuotationVendor(vendor.id, result.data, 'current-user') + const response = await updateBiddingDetailVendor( + vendor.id, + vendor.quotationAmount, // 기존 견적금액 유지 + vendor.currency, // 기존 통화 유지 + formData.awardRatio, + 'current-user' // TODO: 실제 사용자 ID + ) if (response.success) { toast({ @@ -134,209 +90,40 @@ export function BiddingDetailVendorEditDialog({ <Dialog open={open} onOpenChange={onOpenChange}> <DialogContent className="sm:max-w-[600px]"> <DialogHeader> - <DialogTitle>협력업체 수정</DialogTitle> + <DialogTitle>협력업체 발주비율 산정</DialogTitle> <DialogDescription> - 협력업체 정보를 수정해주세요. + 협력업체 발주비율을 산정해주세요. </DialogDescription> </DialogHeader> <div className="grid gap-4 py-4"> - <div className="grid grid-cols-2 gap-4"> - <div className="space-y-2"> - <Label htmlFor="edit-vendorName">업체명</Label> - <Input - id="edit-vendorName" - value={formData.vendorName} - onChange={(e) => setFormData({ ...formData, vendorName: e.target.value })} - /> - </div> - <div className="space-y-2"> - <Label htmlFor="edit-vendorCode">업체코드</Label> - <Input - id="edit-vendorCode" - value={formData.vendorCode} - onChange={(e) => setFormData({ ...formData, vendorCode: e.target.value })} - /> - </div> - </div> - <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-quotationAmount">견적금액</Label> - <Input - id="edit-quotationAmount" - type="number" - value={formData.quotationAmount} - onChange={(e) => setFormData({ ...formData, quotationAmount: Number(e.target.value) })} - /> - </div> - <div className="space-y-2"> - <Label htmlFor="edit-currency">통화</Label> - <Select value={formData.currency} onValueChange={(value) => setFormData({ ...formData, currency: value })}> - <SelectTrigger> - <SelectValue /> - </SelectTrigger> - <SelectContent> - <SelectItem value="KRW">KRW</SelectItem> - <SelectItem value="USD">USD</SelectItem> - <SelectItem value="EUR">EUR</SelectItem> - </SelectContent> - </Select> - </div> - </div> - <div className="grid grid-cols-2 gap-4"> - <div className="space-y-2"> - <Label htmlFor="edit-awardRatio">발주비율 (%)</Label> - <Input - id="edit-awardRatio" - type="number" - min="0" - max="100" - value={formData.awardRatio} - onChange={(e) => setFormData({ ...formData, awardRatio: Number(e.target.value) })} - /> - </div> - <div className="space-y-2"> - <Label htmlFor="edit-status">상태</Label> - <Select value={formData.status} onValueChange={(value: any) => setFormData({ ...formData, status: value })}> - <SelectTrigger> - <SelectValue /> - </SelectTrigger> - <SelectContent> - <SelectItem value="pending">대기</SelectItem> - <SelectItem value="submitted">제출</SelectItem> - <SelectItem value="selected">선정</SelectItem> - <SelectItem value="rejected">거절</SelectItem> - </SelectContent> - </Select> - </div> - </div> - {/* 입찰 조건 섹션 */} - <div className="col-span-2 pt-4 border-t"> - <h3 className="text-lg font-medium mb-4">입찰 조건 설정</h3> - - <div className="grid grid-cols-2 gap-4"> - <div className="space-y-2"> - <Label htmlFor="edit-paymentTermsResponse">지급조건</Label> - <Input - id="edit-paymentTermsResponse" - value={formData.paymentTermsResponse} - onChange={(e) => setFormData({ ...formData, paymentTermsResponse: e.target.value })} - placeholder="지급조건을 입력하세요" - /> - </div> - - <div className="space-y-2"> - <Label htmlFor="edit-taxConditionsResponse">세금조건</Label> - <Input - id="edit-taxConditionsResponse" - value={formData.taxConditionsResponse} - onChange={(e) => setFormData({ ...formData, taxConditionsResponse: e.target.value })} - placeholder="세금조건을 입력하세요" - /> - </div> - </div> - - <div className="grid grid-cols-2 gap-4 mt-4"> - <div className="space-y-2"> - <Label htmlFor="edit-incotermsResponse">운송조건 (Incoterms)</Label> - <Input - id="edit-incotermsResponse" - value={formData.incotermsResponse} - onChange={(e) => setFormData({ ...formData, incotermsResponse: e.target.value })} - placeholder="운송조건을 입력하세요" - /> - </div> - - <div className="space-y-2"> - <Label htmlFor="edit-proposedContractDeliveryDate">제안 계약납기일</Label> - <Input - id="edit-proposedContractDeliveryDate" - type="date" - value={formData.proposedContractDeliveryDate} - onChange={(e) => setFormData({ ...formData, proposedContractDeliveryDate: e.target.value })} - /> - </div> - </div> - - <div className="grid grid-cols-2 gap-4 mt-4"> - <div className="space-y-2"> - <Label htmlFor="edit-proposedShippingPort">제안 선적지</Label> - <Input - id="edit-proposedShippingPort" - value={formData.proposedShippingPort} - onChange={(e) => setFormData({ ...formData, proposedShippingPort: e.target.value })} - placeholder="선적지를 입력하세요" - /> - </div> - - <div className="space-y-2"> - <Label htmlFor="edit-proposedDestinationPort">제안 도착지</Label> - <Input - id="edit-proposedDestinationPort" - value={formData.proposedDestinationPort} - onChange={(e) => setFormData({ ...formData, proposedDestinationPort: e.target.value })} - placeholder="도착지를 입력하세요" - /> + {/* 읽기 전용 업체 정보 */} + {vendor && ( + <div className="bg-muted/50 rounded-lg p-3 border"> + <h4 className="font-medium mb-2">협력업체 정보</h4> + <div className="grid grid-cols-2 gap-4 text-sm"> + <div> + <span className="text-muted-foreground">협력업체명:</span> {vendor.vendorName} + </div> + <div> + <span className="text-muted-foreground">협력업체코드:</span> {vendor.vendorCode} + </div> </div> </div> - - <div className="space-y-2 mt-4"> - <Label htmlFor="edit-sparePartResponse">스페어파트 응답</Label> - <Input - id="edit-sparePartResponse" - value={formData.sparePartResponse} - onChange={(e) => setFormData({ ...formData, sparePartResponse: e.target.value })} - placeholder="스페어파트 관련 응답을 입력하세요" - /> - </div> - - <div className="space-y-2 mt-4"> - <Label htmlFor="edit-additionalProposals">추가 제안사항</Label> - <Textarea - id="edit-additionalProposals" - value={formData.additionalProposals} - onChange={(e) => setFormData({ ...formData, additionalProposals: e.target.value })} - placeholder="추가 제안사항을 입력하세요" - rows={3} - /> - </div> - - <div className="flex items-center space-x-2 mt-4"> - <Checkbox - id="edit-priceAdjustmentResponse" - checked={formData.priceAdjustmentResponse} - onCheckedChange={(checked) => - setFormData({ ...formData, priceAdjustmentResponse: !!checked }) - } - /> - <Label htmlFor="edit-priceAdjustmentResponse">연동제 적용</Label> - </div> + )} + + {/* 수정 가능한 필드들 */} + + <div className="space-y-2"> + <Label htmlFor="edit-awardRatio">발주비율 (%)</Label> + <Input + id="edit-awardRatio" + type="number" + min="0" + max="100" + value={formData.awardRatio} + onChange={(e) => setFormData({ ...formData, awardRatio: Number(e.target.value) })} + placeholder="발주비율을 입력하세요" + /> </div> </div> <DialogFooter> @@ -344,7 +131,7 @@ export function BiddingDetailVendorEditDialog({ 취소 </Button> <Button onClick={handleEdit} disabled={isPending}> - 수정 + 산정 </Button> </DialogFooter> </DialogContent> diff --git a/lib/bidding/detail/table/bidding-detail-vendor-table.tsx b/lib/bidding/detail/table/bidding-detail-vendor-table.tsx index b1f0b08e..dd1ae94b 100644 --- a/lib/bidding/detail/table/bidding-detail-vendor-table.tsx +++ b/lib/bidding/detail/table/bidding-detail-vendor-table.tsx @@ -8,6 +8,7 @@ import { DataTableAdvancedToolbar } from '@/components/data-table/data-table-adv import { BiddingDetailVendorToolbarActions } from './bidding-detail-vendor-toolbar-actions' import { BiddingDetailVendorCreateDialog } from './bidding-detail-vendor-create-dialog' import { BiddingDetailVendorEditDialog } from './bidding-detail-vendor-edit-dialog' +import { BiddingAwardDialog } from './bidding-award-dialog' import { getBiddingDetailVendorColumns } from './bidding-detail-vendor-columns' import { QuotationVendor, getPriceAdjustmentFormByBiddingCompanyId } from '@/lib/bidding/detail/service' import { Bidding } from '@/db/schema' @@ -28,6 +29,7 @@ interface BiddingDetailVendorTableContentProps { onOpenItemsDialog: () => void onOpenTargetPriceDialog: () => void onOpenSelectionReasonDialog: () => void + onOpenAwardDialog: () => void onEdit?: (vendor: QuotationVendor) => void onDelete?: (vendor: QuotationVendor) => void onSelectWinner?: (vendor: QuotationVendor) => void @@ -92,6 +94,7 @@ export function BiddingDetailVendorTableContent({ onOpenItemsDialog, onOpenTargetPriceDialog, onOpenSelectionReasonDialog, + onOpenAwardDialog, onEdit, onDelete, onSelectWinner @@ -100,6 +103,7 @@ export function BiddingDetailVendorTableContent({ const [isPending, startTransition] = useTransition() const [selectedVendor, setSelectedVendor] = React.useState<QuotationVendor | null>(null) const [isEditDialogOpen, setIsEditDialogOpen] = React.useState(false) + const [isAwardDialogOpen, setIsAwardDialogOpen] = React.useState(false) const [priceAdjustmentData, setPriceAdjustmentData] = React.useState<any>(null) const [isPriceAdjustmentDialogOpen, setIsPriceAdjustmentDialogOpen] = React.useState(false) @@ -240,6 +244,7 @@ export function BiddingDetailVendorTableContent({ onOpenItemsDialog={onOpenItemsDialog} onOpenTargetPriceDialog={onOpenTargetPriceDialog} onOpenSelectionReasonDialog={onOpenSelectionReasonDialog} + onOpenAwardDialog={() => setIsAwardDialogOpen(true)} onSuccess={onRefresh} /> </DataTableAdvancedToolbar> @@ -252,6 +257,13 @@ export function BiddingDetailVendorTableContent({ onSuccess={onRefresh} /> + <BiddingAwardDialog + biddingId={biddingId} + open={isAwardDialogOpen} + onOpenChange={setIsAwardDialogOpen} + onSuccess={onRefresh} + /> + <PriceAdjustmentDialog open={isPriceAdjustmentDialogOpen} onOpenChange={setIsPriceAdjustmentDialogOpen} 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 ca9ffc60..8cdec191 100644 --- a/lib/bidding/detail/table/bidding-detail-vendor-toolbar-actions.tsx +++ b/lib/bidding/detail/table/bidding-detail-vendor-toolbar-actions.tsx @@ -5,8 +5,8 @@ import { type Table } from "@tanstack/react-table" import { useRouter } from "next/navigation" import { useTransition } from "react" import { Button } from "@/components/ui/button" -import { Plus, Send, RotateCcw, XCircle } from "lucide-react" -import { QuotationVendor, registerBidding, markAsDisposal, createRebidding } from "@/lib/bidding/detail/service" +import { Plus, Send, RotateCcw, XCircle, Trophy } from "lucide-react" +import { QuotationVendor, registerBidding, markAsDisposal, createRebidding, awardBidding } from "@/lib/bidding/detail/service" import { BiddingDetailVendorCreateDialog } from "./bidding-detail-vendor-create-dialog" import { Bidding } from "@/db/schema" import { useToast } from "@/hooks/use-toast" @@ -17,7 +17,7 @@ interface BiddingDetailVendorToolbarActionsProps { bidding: Bidding onOpenItemsDialog: () => void onOpenTargetPriceDialog: () => void - onOpenSelectionReasonDialog: () => void + onOpenAwardDialog: () => void onSuccess: () => void } @@ -27,7 +27,7 @@ export function BiddingDetailVendorToolbarActions({ bidding, onOpenItemsDialog, onOpenTargetPriceDialog, - onOpenSelectionReasonDialog, + onOpenAwardDialog, onSuccess }: BiddingDetailVendorToolbarActionsProps) { const router = useRouter() @@ -40,18 +40,6 @@ export function BiddingDetailVendorToolbarActions({ } const handleRegister = () => { - // 상태 검증 - if (bidding.status !== 'bidding_generated') { - toast({ - title: '실행 불가', - description: '입찰 등록은 입찰 생성 상태에서만 가능합니다.', - variant: 'destructive', - }) - return - } - - if (!confirm('입찰을 등록하시겠습니까?')) return - startTransition(async () => { const result = await registerBidding(bidding.id, 'current-user') // TODO: 실제 사용자 ID @@ -72,18 +60,6 @@ export function BiddingDetailVendorToolbarActions({ } const handleMarkAsDisposal = () => { - // 상태 검증 - if (bidding.status !== 'bidding_closed') { - toast({ - title: '실행 불가', - description: '유찰 처리는 입찰 마감 상태에서만 가능합니다.', - variant: 'destructive', - }) - return - } - - if (!confirm('입찰을 유찰 처리하시겠습니까?')) return - startTransition(async () => { const result = await markAsDisposal(bidding.id, 'current-user') // TODO: 실제 사용자 ID @@ -104,18 +80,6 @@ export function BiddingDetailVendorToolbarActions({ } const handleCreateRebidding = () => { - // 상태 검증 - if (bidding.status !== 'bidding_disposal') { - toast({ - title: '실행 불가', - description: '재입찰은 유찰 상태에서만 가능합니다.', - variant: 'destructive', - }) - return - } - - if (!confirm('재입찰을 생성하시겠습니까?')) return - startTransition(async () => { const result = await createRebidding(bidding.id, 'current-user') // TODO: 실제 사용자 ID @@ -124,11 +88,8 @@ export function BiddingDetailVendorToolbarActions({ title: '성공', description: result.message, }) - if (result.data?.redirectTo) { - router.push(result.data.redirectTo) - } else { - router.refresh() - } + router.refresh() + onSuccess() } else { toast({ title: '오류', @@ -143,7 +104,7 @@ export function BiddingDetailVendorToolbarActions({ <> <div className="flex items-center gap-2"> {/* 상태별 액션 버튼 */} - {/* {bidding.status === 'bidding_generated' && ( + {bidding.status === 'bidding_generated' && ( <Button variant="default" size="sm" @@ -156,15 +117,26 @@ export function BiddingDetailVendorToolbarActions({ )} {bidding.status === 'bidding_closed' && ( - <Button - variant="destructive" - size="sm" - onClick={handleMarkAsDisposal} - disabled={isPending} - > - <XCircle className="mr-2 h-4 w-4" /> - 유찰 처리 - </Button> + <> + <Button + variant="destructive" + size="sm" + onClick={handleMarkAsDisposal} + disabled={isPending} + > + <XCircle className="mr-2 h-4 w-4" /> + 유찰 + </Button> + <Button + variant="default" + size="sm" + onClick={onOpenAwardDialog} + disabled={isPending} + > + <Trophy className="mr-2 h-4 w-4" /> + 낙찰 + </Button> + </> )} {bidding.status === 'bidding_disposal' && ( @@ -175,11 +147,18 @@ export function BiddingDetailVendorToolbarActions({ disabled={isPending} > <RotateCcw className="mr-2 h-4 w-4" /> - 재입찰 생성 + 재입찰 </Button> - )} */} + )} + + {/* 구분선 */} + {(bidding.status === 'bidding_generated' || + bidding.status === 'bidding_closed' || + bidding.status === 'bidding_disposal') && ( + <div className="h-4 w-px bg-border mx-1" /> + )} - {/* 기존 버튼들 */} + {/* 공통 관리 버튼들 */} <Button variant="outline" size="sm" @@ -197,13 +176,6 @@ export function BiddingDetailVendorToolbarActions({ <Button variant="outline" size="sm" - onClick={onOpenSelectionReasonDialog} - > - 선정 사유 - </Button> - <Button - variant="default" - size="sm" onClick={handleCreateVendor} > <Plus className="mr-2 h-4 w-4" /> diff --git a/lib/bidding/detail/table/components/award-simple-file-upload.tsx b/lib/bidding/detail/table/components/award-simple-file-upload.tsx new file mode 100644 index 00000000..c19918f6 --- /dev/null +++ b/lib/bidding/detail/table/components/award-simple-file-upload.tsx @@ -0,0 +1,307 @@ +'use client' + +import * as React from 'react' +import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card' +import { Button } from '@/components/ui/button' +import { Input } from '@/components/ui/input' +import { Label } from '@/components/ui/label' +import { Badge } from '@/components/ui/badge' +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from '@/components/ui/table' +import { + Upload, + FileText, + Download, + Trash2 +} from 'lucide-react' +import { useToast } from '@/hooks/use-toast' +import { useTransition } from 'react' +import { + uploadAwardDocument, + getAwardDocuments, + getAwardDocumentForDownload, + deleteAwardDocument +} 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: Date + uploadedBy: string +} + +interface AwardSimpleFileUploadProps { + biddingId: number + userId: string + readOnly?: boolean +} + +export function AwardSimpleFileUpload({ + biddingId, + userId, + readOnly = false +}: AwardSimpleFileUploadProps) { + const { toast } = useToast() + const [isPending, startTransition] = useTransition() + const [documents, setDocuments] = React.useState<UploadedDocument[]>([]) + const [isLoading, setIsLoading] = React.useState(true) + + // 업로드된 문서 목록 로드 + const loadDocuments = React.useCallback(async () => { + try { + setIsLoading(true) + const docs = await getAwardDocuments(biddingId) + setDocuments(docs as UploadedDocument[]) + } catch (error) { + console.error('Failed to load documents:', error) + toast({ + title: '오류', + description: '업로드된 문서 목록을 불러오는데 실패했습니다.', + variant: 'destructive', + }) + } finally { + setIsLoading(false) + } + }, [biddingId, toast]) + + React.useEffect(() => { + loadDocuments() + }, [loadDocuments]) + + // 파일 업로드 처리 + const handleFileUpload = (event: React.ChangeEvent<HTMLInputElement>) => { + const files = event.target.files + if (!files || files.length === 0) return + + const file = files[0] + + // 파일 크기 체크 (50MB 제한) + if (file.size > 50 * 1024 * 1024) { + toast({ + title: '파일 크기 초과', + description: '파일 크기가 50MB를 초과합니다.', + variant: 'destructive', + }) + return + } + + // 파일 타입 체크 + const allowedTypes = [ + 'application/pdf', + 'application/msword', + 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', + 'application/vnd.ms-excel', + 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', + 'image/jpeg', + 'image/png', + 'application/zip' + ] + + if (!allowedTypes.includes(file.type)) { + toast({ + title: '지원하지 않는 파일 형식', + description: 'PDF, Word, Excel, 이미지, ZIP 파일만 업로드 가능합니다.', + variant: 'destructive', + }) + return + } + + startTransition(async () => { + const result = await uploadAwardDocument(biddingId, file, userId) + + if (result.success) { + toast({ + title: '업로드 완료', + description: result.message, + }) + await loadDocuments() // 문서 목록 새로고침 + } else { + toast({ + title: '업로드 실패', + description: result.error, + variant: 'destructive', + }) + } + }) + + // input 초기화 + event.target.value = '' + } + + // 파일 다운로드 + const handleDownload = (document: UploadedDocument) => { + startTransition(async () => { + const result = await getAwardDocumentForDownload(document.id, biddingId) + + 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 handleDelete = (document: UploadedDocument) => { + if (!confirm(`"${document.originalFileName}" 파일을 삭제하시겠습니까?`)) { + return + } + + startTransition(async () => { + const result = await deleteAwardDocument(document.id, biddingId, userId) + + if (result.success) { + toast({ + title: '삭제 완료', + description: result.message, + }) + await loadDocuments() // 문서 목록 새로고침 + } 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 ( + <Card> + <CardHeader> + <CardTitle className="flex items-center gap-2"> + <FileText className="w-5 h-5" /> + 낙찰 관련 문서 업로드 + </CardTitle> + </CardHeader> + <CardContent className="space-y-4"> + {!readOnly && ( + <div className="space-y-2"> + <Label htmlFor="award-file-upload">낙찰 관련 파일</Label> + <Input + id="award-file-upload" + type="file" + accept=".pdf,.doc,.docx,.xls,.xlsx,.jpg,.jpeg,.png,.zip" + onChange={handleFileUpload} + disabled={isPending} + /> + <p className="text-xs text-muted-foreground"> + 지원 형식: PDF, Word, Excel, 이미지, ZIP (최대 50MB) + </p> + </div> + )} + + {/* 업로드된 문서 목록 */} + {isLoading ? ( + <div className="text-center py-4"> + <p className="text-muted-foreground">문서 목록을 불러오는 중...</p> + </div> + ) : documents.length > 0 ? ( + <div className="space-y-2"> + <Label className="text-sm font-medium">업로드된 문서</Label> + <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"> + {new Date(doc.uploadedAt).toLocaleDateString('ko-KR')} + </TableCell> + <TableCell className="text-sm text-gray-500"> + {doc.uploadedBy} + </TableCell> + <TableCell> + <div className="flex items-center gap-1"> + <Button + variant="outline" + size="sm" + onClick={() => handleDownload(doc)} + disabled={isPending} + title="다운로드" + > + <Download className="w-3 h-3" /> + </Button> + {!readOnly && doc.uploadedBy === userId && ( + <Button + variant="outline" + size="sm" + onClick={() => handleDelete(doc)} + disabled={isPending} + title="삭제" + className="text-red-600 hover:text-red-700" + > + <Trash2 className="w-3 h-3" /> + </Button> + )} + </div> + </TableCell> + </TableRow> + ))} + </TableBody> + </Table> + </div> + ) : ( + <div className="text-center py-4 text-gray-500"> + <FileText className="w-8 h-8 mx-auto mb-2 opacity-50" /> + <p className="text-sm">업로드된 문서가 없습니다</p> + </div> + )} + </CardContent> + </Card> + ) +} diff --git a/lib/bidding/list/biddings-table-columns.tsx b/lib/bidding/list/biddings-table-columns.tsx index 48a77954..ed9d20e3 100644 --- a/lib/bidding/list/biddings-table-columns.tsx +++ b/lib/bidding/list/biddings-table-columns.tsx @@ -540,7 +540,7 @@ export function getBiddingsColumns({ setRowAction }: GetColumnsProps): ColumnDef // ═══════════════════════════════════════════════════════════════ { id: "actions", - header: "작업", + header: "액션", cell: ({ row }) => ( <DropdownMenu> <DropdownMenuTrigger asChild> diff --git a/lib/bidding/list/create-bidding-dialog.tsx b/lib/bidding/list/create-bidding-dialog.tsx index e5bfcae4..88697903 100644 --- a/lib/bidding/list/create-bidding-dialog.tsx +++ b/lib/bidding/list/create-bidding-dialog.tsx @@ -114,6 +114,8 @@ interface PRItemInfo { itemInfo: string quantity: string quantityUnit: string + totalWeight: string + weightUnit: string requestedDeliveryDate: string specFiles: File[] isRepresentative: boolean // 대표 아이템 여부 @@ -341,6 +343,8 @@ export function CreateBiddingDialog() { itemInfo: "", quantity: "", quantityUnit: "EA", + totalWeight: "", + weightUnit: "KG", requestedDeliveryDate: "", specFiles: [], isRepresentative: prItems.length === 0, // 첫 번째 아이템은 자동으로 대표 아이템 @@ -1438,6 +1442,8 @@ export function CreateBiddingDialog() { <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> @@ -1507,6 +1513,31 @@ export function CreateBiddingDialog() { </TableCell> <TableCell> <Input + type="number" + 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 })} diff --git a/lib/bidding/pre-quote/service.ts b/lib/bidding/pre-quote/service.ts index 7f0a9083..b5b06769 100644 --- a/lib/bidding/pre-quote/service.ts +++ b/lib/bidding/pre-quote/service.ts @@ -127,6 +127,33 @@ export async function updateBiddingCompany(id: number, input: UpdateBiddingCompa } } +// 본입찰 등록 상태 업데이트 (복수 업체 선택 가능) +export async function updatePreQuoteSelection(companyIds: number[], isSelected: boolean, userId: string) { + try { + await db.update(biddingCompanies) + .set({ + isPreQuoteSelected: isSelected, + updatedAt: new Date() + }) + .where(inArray(biddingCompanies.id, companyIds)) + + 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 { @@ -302,6 +329,17 @@ export async function sendPreQuoteInvitations(companyIds: number[]) { } } } + // 3. 입찰 상태를 사전견적 요청으로 변경 (bidding_generated 상태에서만) + await tx + .update(biddings) + .set({ + status: 'request_for_quotation', + updatedAt: new Date() + }) + .where(and( + eq(biddings.id, biddingId), + eq(biddings.status, 'bidding_generated') + )) return { success: true, @@ -556,6 +594,28 @@ export async function submitPreQuoteResponse( 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', + preQuoteReceivedAt: new Date(), // 사전견적 접수일 업데이트 + updatedAt: new Date() + }) + .where(and( + eq(biddings.id, biddingCompany[0].biddingId), + eq(biddings.status, 'request_for_quotation') + )) + } }) return { @@ -648,6 +708,8 @@ export async function getPrItemsForBidding(biddingId: number) { materialDescription: prItemsForBidding.materialDescription, quantity: prItemsForBidding.quantity, quantityUnit: prItemsForBidding.quantityUnit, + totalWeight: prItemsForBidding.totalWeight, + weightUnit: prItemsForBidding.weightUnit, currency: prItemsForBidding.currency, requestedDeliveryDate: prItemsForBidding.requestedDeliveryDate, hasSpecDocument: prItemsForBidding.hasSpecDocument @@ -665,7 +727,6 @@ export async function getPrItemsForBidding(biddingId: number) { // SPEC 문서 조회 (PR 아이템에 연결된 문서들) export async function getSpecDocumentsForPrItem(prItemId: number) { try { - console.log('getSpecDocumentsForPrItem called with prItemId:', prItemId) const specDocs = await db .select({ @@ -686,7 +747,6 @@ export async function getSpecDocumentsForPrItem(prItemId: number) { ) ) - console.log('getSpecDocumentsForPrItem result:', specDocs) return specDocs } catch (error) { console.error('Failed to get spec documents for PR item:', error) 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 new file mode 100644 index 00000000..7de79771 --- /dev/null +++ b/lib/bidding/pre-quote/table/bidding-pre-quote-selection-dialog.tsx @@ -0,0 +1,158 @@ +'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, + 'current-user' // TODO: 실제 사용자 ID + ) + + 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 index f28f9e1f..7e84f178 100644 --- a/lib/bidding/pre-quote/table/bidding-pre-quote-vendor-columns.tsx +++ b/lib/bidding/pre-quote/table/bidding-pre-quote-vendor-columns.tsx @@ -317,7 +317,7 @@ export function getBiddingPreQuoteVendorColumns({ }, { id: 'actions', - header: '작업', + header: '액션', cell: ({ row }) => { const company = row.original @@ -330,7 +330,6 @@ export function getBiddingPreQuoteVendorColumns({ </Button> </DropdownMenuTrigger> <DropdownMenuContent align="end"> - <DropdownMenuLabel>작업</DropdownMenuLabel> {/* <DropdownMenuItem onClick={() => onEdit(company)}> <Edit className="mr-2 h-4 w-4" /> 수정 @@ -341,7 +340,6 @@ export function getBiddingPreQuoteVendorColumns({ 초대 발송 </DropdownMenuItem> )} - <DropdownMenuSeparator /> <DropdownMenuItem onClick={() => onDelete(company)} className="text-destructive" 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 index c1b1baa5..6c209e2d 100644 --- 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 @@ -4,10 +4,11 @@ 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 } from "lucide-react" +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" @@ -36,6 +37,7 @@ export function BiddingPreQuoteVendorToolbarActions({ 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) @@ -45,6 +47,19 @@ export function BiddingPreQuoteVendorToolbarActions({ setIsInvitationDialogOpen(true) } + const handleManageSelection = () => { + const selectedRows = table.getFilteredSelectedRowModel().rows + if (selectedRows.length === 0) { + toast({ + title: '선택 필요', + description: '본입찰 선정 상태를 변경할 업체를 선택해주세요.', + variant: 'destructive', + }) + return + } + setIsSelectionDialogOpen(true) + } + return ( @@ -69,6 +84,16 @@ export function BiddingPreQuoteVendorToolbarActions({ <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 @@ -87,6 +112,16 @@ export function BiddingPreQuoteVendorToolbarActions({ companies={biddingCompanies} onSuccess={onSuccess} /> + + <BiddingPreQuoteSelectionDialog + open={isSelectionDialogOpen} + onOpenChange={setIsSelectionDialogOpen} + selectedCompanies={table.getFilteredSelectedRowModel().rows.map(row => row.original)} + onSuccess={() => { + onSuccess() + table.resetRowSelection() + }} + /> </> ) } diff --git a/lib/bidding/service.ts b/lib/bidding/service.ts index ef404561..8c99bfed 100644 --- a/lib/bidding/service.ts +++ b/lib/bidding/service.ts @@ -639,8 +639,8 @@ export async function createBidding(input: CreateBiddingInput, userId: string) { currency: 'KRW', // 기본값 또는 입력받은 값 quantity: prItem.quantity ? parseFloat(prItem.quantity) : null, quantityUnit: prItem.quantityUnit as any, // enum 타입에 맞게 - totalWeight: null, // 필요시 추가 - weightUnit: null, // 필요시 추가 + totalWeight: prItem.totalWeight ? parseFloat(prItem.totalWeight) : null, + weightUnit: prItem.weightUnit as any, // enum 타입에 맞게 materialDescription: '', // 필요시 추가 prNumber: prItem.prNumber, hasSpecDocument: prItem.specFiles.length > 0, diff --git a/lib/bidding/vendor/components/pr-items-pricing-table.tsx b/lib/bidding/vendor/components/pr-items-pricing-table.tsx index 01885f7a..1dee7adb 100644 --- a/lib/bidding/vendor/components/pr-items-pricing-table.tsx +++ b/lib/bidding/vendor/components/pr-items-pricing-table.tsx @@ -39,6 +39,8 @@ interface PrItem { materialDescription: string | null quantity: string | null quantityUnit: string | null + totalWeight: string | null + weightUnit: string | null currency: string | null requestedDeliveryDate: string | null hasSpecDocument: boolean | null @@ -221,11 +223,20 @@ export function PrItemsPricingTable({ if (q.prItemId === prItemId) { const updated = { ...q, [field]: value } - // 단가나 수량이 변경되면 금액 자동 계산 + // 단가가 변경되면 금액 자동 계산 (수량 우선, 없으면 중량 사용) if (field === 'bidUnitPrice') { const prItem = prItems.find(item => item.id === prItemId) - const quantity = parseFloat(prItem?.quantity || '1') - updated.bidAmount = updated.bidUnitPrice * quantity + let multiplier = 1 + + if (prItem?.quantity && parseFloat(prItem.quantity) > 0) { + // 수량이 있으면 수량 기준 + multiplier = parseFloat(prItem.quantity) + } else if (prItem?.totalWeight && parseFloat(prItem.totalWeight) > 0) { + // 수량이 없으면 중량 기준 + multiplier = parseFloat(prItem.totalWeight) + } + + updated.bidAmount = updated.bidUnitPrice * multiplier } return updated @@ -273,6 +284,8 @@ export function PrItemsPricingTable({ <TableHead>자재내역</TableHead> <TableHead>수량</TableHead> <TableHead>단위</TableHead> + <TableHead>중량</TableHead> + <TableHead>중량단위</TableHead> <TableHead>견적단가</TableHead> <TableHead>견적금액</TableHead> <TableHead>납품예정일</TableHead> @@ -310,6 +323,10 @@ export function PrItemsPricingTable({ {item.quantity ? parseFloat(item.quantity).toLocaleString() : '-'} </TableCell> <TableCell>{item.quantityUnit || '-'}</TableCell> + <TableCell className="text-right"> + {item.totalWeight ? parseFloat(item.totalWeight).toLocaleString() : '-'} + </TableCell> + <TableCell>{item.weightUnit || '-'}</TableCell> <TableCell> {readOnly ? ( <span className="font-medium"> diff --git a/lib/bidding/vendor/partners-bidding-detail.tsx b/lib/bidding/vendor/partners-bidding-detail.tsx index 1e6ae479..8d24ca66 100644 --- a/lib/bidding/vendor/partners-bidding-detail.tsx +++ b/lib/bidding/vendor/partners-bidding-detail.tsx @@ -20,15 +20,21 @@ import { Users, Send, CheckCircle, - XCircle + XCircle, + Save } from 'lucide-react' import { formatDate } from '@/lib/utils' import { getBiddingDetailsForPartners, submitPartnerResponse, - updatePartnerAttendance + updatePartnerAttendance, + updatePartnerBiddingParticipation, + saveBiddingDraft } from '../detail/service' +import { getPrItemsForBidding, getSavedPrItemQuotations } from '@/lib/bidding/pre-quote/service' +import { PrItemsPricingTable } from './components/pr-items-pricing-table' +import { SimpleFileUpload } from './components/simple-file-upload' import { biddingStatusLabels, contractTypeLabels, @@ -36,6 +42,7 @@ import { } from '@/db/schema' import { useToast } from '@/hooks/use-toast' import { useTransition } from 'react' +import { useSession } from 'next-auth/react' interface PartnersBiddingDetailProps { biddingId: number @@ -45,115 +52,144 @@ interface PartnersBiddingDetailProps { interface BiddingDetail { id: number biddingNumber: string - revision: number - projectName: string - itemName: string + revision: number | null + projectName: string | null + itemName: string | null title: string - description: string - content: string + description: string | null + content: string | null contractType: string biddingType: string awardCount: string - contractPeriod: string - preQuoteDate: string - biddingRegistrationDate: string - submissionStartDate: string - submissionEndDate: string - evaluationDate: string + contractPeriod: string | null + preQuoteDate: string | null + biddingRegistrationDate: string | null + submissionStartDate: string | null + submissionEndDate: string | null + evaluationDate: string | null currency: string - budget: number - targetPrice: number + budget: number | null + targetPrice: number | null status: string - managerName: string - managerEmail: string - managerPhone: string + managerName: string | null + managerEmail: string | null + managerPhone: string | null biddingCompanyId: number - biddingId: number // bidding의 ID 추가 + biddingId: number invitationStatus: string - finalQuoteAmount: number - finalQuoteSubmittedAt: string + finalQuoteAmount: number | null + finalQuoteSubmittedAt: string | null isWinner: boolean isAttendingMeeting: boolean | null - // companyConditionResponses에서 가져온 조건들 (제시된 조건과 응답 모두) - paymentTermsResponse: string - taxConditionsResponse: string - incotermsResponse: string - proposedContractDeliveryDate: string - proposedShippingPort: string - proposedDestinationPort: string - priceAdjustmentResponse: boolean - sparePartResponse: string - additionalProposals: string - responseSubmittedAt: string + isBiddingParticipated: boolean | null + additionalProposals: string | null + responseSubmittedAt: string | null +} + +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 | null + technicalSpecification?: string } export function PartnersBiddingDetail({ biddingId, companyId }: PartnersBiddingDetailProps) { const router = useRouter() const { toast } = useToast() + const session = useSession() const [isPending, startTransition] = useTransition() const [biddingDetail, setBiddingDetail] = React.useState<BiddingDetail | null>(null) const [isLoading, setIsLoading] = React.useState(true) + const [isUpdatingParticipation, setIsUpdatingParticipation] = React.useState(false) + const [isSavingDraft, setIsSavingDraft] = React.useState(false) + const [isSubmitting, setIsSubmitting] = React.useState(false) + + // 품목별 견적 관련 상태 + const [prItems, setPrItems] = React.useState<PrItem[]>([]) + const [prItemQuotations, setPrItemQuotations] = React.useState<PrItemQuotation[]>([]) + const [totalQuotationAmount, setTotalQuotationAmount] = React.useState(0) // 응찰 폼 상태 const [responseData, setResponseData] = React.useState({ finalQuoteAmount: '', - paymentTermsResponse: '', - taxConditionsResponse: '', - incotermsResponse: '', proposedContractDeliveryDate: '', - proposedShippingPort: '', - proposedDestinationPort: '', - priceAdjustmentResponse: false, - isInitialResponse: false, - sparePartResponse: '', additionalProposals: '', - isAttendingMeeting: false, }) + const userId = session.data?.user?.id || '' - // 연동제 폼 상태 - const [priceAdjustmentForm, setPriceAdjustmentForm] = React.useState({ - itemName: '', - adjustmentReflectionPoint: '', - majorApplicableRawMaterial: '', - adjustmentFormula: '', - rawMaterialPriceIndex: '', - referenceDate: '', - comparisonDate: '', - adjustmentRatio: '', - notes: '', - adjustmentConditions: '', - majorNonApplicableRawMaterial: '', - adjustmentPeriod: '', - contractorWriter: '', - adjustmentDate: '', - nonApplicableReason: '', - }) // 데이터 로드 React.useEffect(() => { const loadData = async () => { try { setIsLoading(true) - const result = await getBiddingDetailsForPartners(biddingId, companyId) + const [result, prItemsResult] = await Promise.all([ + getBiddingDetailsForPartners(biddingId, companyId), + getPrItemsForBidding(biddingId) + ]) + if (result) { setBiddingDetail(result) // 기존 응답 데이터로 폼 초기화 setResponseData({ finalQuoteAmount: result.finalQuoteAmount?.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, }) } + + // PR 아이템 설정 + setPrItems(prItemsResult) + + // 사전견적 데이터를 본입찰용으로 로드 (응찰 확정 시 또는 사전견적이 있는 경우) + if (result?.biddingCompanyId) { + try { + // 사전견적 데이터를 가져와서 본입찰용으로 변환 + const preQuoteData = await getSavedPrItemQuotations(result.biddingCompanyId) + + // 사전견적 데이터를 본입찰 포맷으로 변환 + const convertedQuotations = preQuoteData.map(item => ({ + prItemId: item.prItemId, + bidUnitPrice: item.bidUnitPrice, + bidAmount: item.bidAmount, + proposedDeliveryDate: item.proposedDeliveryDate || '', + technicalSpecification: item.technicalSpecification || undefined + })) + + setPrItemQuotations(convertedQuotations) + + // 총 금액 계산 + const total = convertedQuotations.reduce((sum, q) => sum + q.bidAmount, 0) + setTotalQuotationAmount(total) + + // 응찰 확정 시에만 사전견적 금액을 finalQuoteAmount로 설정 + if (total > 0 && result.isBiddingParticipated === true) { + setResponseData(prev => ({ + ...prev, + finalQuoteAmount: total.toString() + })) + } + } catch (error) { + console.error('Failed to load pre-quote data:', error) + } + } } catch (error) { console.error('Failed to load bidding detail:', error) toast({ @@ -169,53 +205,16 @@ export function PartnersBiddingDetail({ biddingId, companyId }: PartnersBiddingD loadData() }, [biddingId, companyId, toast]) - const handleSubmitResponse = () => { + // 입찰 참여여부 결정 핸들러 + const handleParticipationDecision = async (participated: boolean) => { if (!biddingDetail) return - // 필수값 검증 - if (!responseData.finalQuoteAmount.trim()) { - toast({ - title: '유효성 오류', - description: '견적 금액을 입력해주세요.', - variant: 'destructive', - }) - return - } - - startTransition(async () => { - const result = await submitPartnerResponse( + setIsUpdatingParticipation(true) + try { + const result = await updatePartnerBiddingParticipation( biddingDetail.biddingCompanyId, - { - finalQuoteAmount: parseFloat(responseData.finalQuoteAmount), - 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, - priceAdjustmentForm: responseData.priceAdjustmentResponse ? { - 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 - }, - 'current-user' // TODO: 실제 사용자 ID + participated, + userId ) if (result.success) { @@ -236,6 +235,169 @@ export function PartnersBiddingDetail({ biddingId, companyId }: PartnersBiddingD variant: 'destructive', }) } + } catch (error) { + console.error('Failed to update participation:', error) + toast({ + title: '오류', + description: '참여여부 업데이트에 실패했습니다.', + variant: 'destructive', + }) + } finally { + setIsUpdatingParticipation(false) + } + } + + // 품목별 견적 변경 핸들러 + const handleQuotationsChange = (quotations: PrItemQuotation[]) => { + console.log('견적 변경:', quotations) + setPrItemQuotations(quotations) + } + + // 총 금액 변경 핸들러 + const handleTotalAmountChange = (total: number) => { + setTotalQuotationAmount(total) + // 자동으로 총 견적 금액도 업데이트 + setResponseData(prev => ({ + ...prev, + finalQuoteAmount: total.toString() + })) + } + + // 임시 저장 핸들러 + const handleSaveDraft = async () => { + if (!biddingDetail || !userId) return + + if (prItemQuotations.length === 0) { + toast({ + title: '저장할 데이터 없음', + description: '저장할 품목별 견적이 없습니다.', + variant: 'destructive', + }) + return + } + + setIsSavingDraft(true) + try { + const quotationsForSave = prItemQuotations.map(q => ({ + prItemId: q.prItemId, + bidUnitPrice: q.bidUnitPrice, + bidAmount: q.bidAmount, + proposedDeliveryDate: q.proposedDeliveryDate || undefined, + technicalSpecification: q.technicalSpecification + })) + + console.log('임시저장 - prItemQuotations:', prItemQuotations) + console.log('임시저장 - quotationsForSave:', quotationsForSave) + + const result = await saveBiddingDraft( + biddingDetail.biddingCompanyId, + quotationsForSave, + userId + ) + + if (result.success) { + toast({ + title: '임시 저장 완료', + description: '품목별 견적이 임시 저장되었습니다.', + }) + } else { + toast({ + title: '임시 저장 실패', + description: result.error, + variant: 'destructive', + }) + } + } catch (error) { + console.error('Failed to save draft:', error) + toast({ + title: '오류', + description: '임시 저장에 실패했습니다.', + variant: 'destructive', + }) + } finally { + setIsSavingDraft(false) + } + } + + const handleSubmitResponse = () => { + if (!biddingDetail) return + + // 필수값 검증 + if (!responseData.finalQuoteAmount.trim()) { + toast({ + title: '유효성 오류', + description: '견적 금액을 입력해주세요.', + variant: 'destructive', + }) + return + } + + // 품목별 견적이 있는지 확인 + if (prItems.length > 0 && prItemQuotations.length === 0) { + toast({ + title: '유효성 오류', + description: '품목별 견적을 작성해주세요.', + variant: 'destructive', + }) + return + } + + setIsSubmitting(true) + startTransition(async () => { + try { + // 1. 입찰 참여 상태를 응찰로 변경 + const participationResult = await updatePartnerBiddingParticipation( + biddingDetail.biddingCompanyId, + true, // 응찰 + userId + ) + + if (!participationResult.success) { + throw new Error(participationResult.error) + } + + // 2. 최종 견적 응답 제출 (PR 아이템별 견적 포함) + const result = await submitPartnerResponse( + biddingDetail.biddingCompanyId, + { + finalQuoteAmount: parseFloat(responseData.finalQuoteAmount), + proposedContractDeliveryDate: responseData.proposedContractDeliveryDate, + additionalProposals: responseData.additionalProposals, + prItemQuotations: prItemQuotations.length > 0 ? prItemQuotations.map(q => ({ + prItemId: q.prItemId, + bidUnitPrice: q.bidUnitPrice, + bidAmount: q.bidAmount, + proposedDeliveryDate: q.proposedDeliveryDate || undefined, + technicalSpecification: q.technicalSpecification + })) : undefined, + }, + userId + ) + + if (result.success) { + toast({ + title: '응찰 완료', + description: '견적이 성공적으로 제출되었습니다.', + }) + + // 데이터 새로고침 + const updatedDetail = await getBiddingDetailsForPartners(biddingId, companyId) + if (updatedDetail) { + setBiddingDetail(updatedDetail) + } + } else { + throw new Error(result.error) + } + } catch (error) { + console.error('Failed to submit response:', error) + toast({ + title: '오류', + description: error instanceof Error ? error.message : '응찰 제출에 실패했습니다.', + variant: 'destructive', + }) + } finally { + setIsSubmitting(false) + } }) } @@ -296,6 +458,28 @@ export function PartnersBiddingDetail({ biddingId, companyId }: PartnersBiddingD </div> </div> + {/* 입찰 참여여부 상태 표시 */} + <div className="flex items-center gap-2"> + {biddingDetail.isBiddingParticipated === null ? ( + <div className="flex items-center gap-2"> + <Badge variant="outline">참여 결정 대기</Badge> + <Button + onClick={() => handleParticipationDecision(false)} + disabled={isUpdatingParticipation} + variant="destructive" + size="sm" + > + <XCircle className="w-4 h-4 mr-1" /> + 미응찰 + </Button> + </div> + ) : ( + <Badge variant={biddingDetail.isBiddingParticipated ? 'default' : 'destructive'}> + {biddingDetail.isBiddingParticipated ? '응찰' : '미응찰'} + </Badge> + )} + </div> + </div> {/* 입찰 공고 섹션 */} @@ -312,14 +496,14 @@ export function PartnersBiddingDetail({ biddingId, companyId }: PartnersBiddingD <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> + <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> + <span>{biddingDetail.itemName || '미설정'}</span> </div> </div> <div> @@ -338,7 +522,7 @@ export function PartnersBiddingDetail({ biddingId, companyId }: PartnersBiddingD <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.managerName || '미설정'}</span> </div> </div> </div> @@ -372,73 +556,29 @@ export function PartnersBiddingDetail({ biddingId, companyId }: PartnersBiddingD </CardContent> </Card> - {/* 현재 설정된 조건 섹션 */} + + {/* 참여 상태에 따른 섹션 표시 */} + {biddingDetail.isBiddingParticipated === false ? ( + /* 미응찰 상태 표시 */ <Card> <CardHeader> - <CardTitle>현재 설정된 입찰 조건</CardTitle> + <CardTitle className="flex items-center gap-2"> + <XCircle className="w-5 h-5 text-destructive" /> + 입찰 참여 거절 + </CardTitle> </CardHeader> <CardContent> - <div className="grid grid-cols-2 gap-4"> - <div> - <Label className="text-sm font-medium">지급조건</Label> - <div className="mt-1 p-3 bg-muted rounded-md"> - {biddingDetail.paymentTermsResponse} - </div> - </div> - - <div> - <Label className="text-sm font-medium">세금조건</Label> - <div className="mt-1 p-3 bg-muted rounded-md"> - {biddingDetail.taxConditionsResponse} - </div> - </div> - - <div> - <Label className="text-sm font-medium">운송조건</Label> - <div className="mt-1 p-3 bg-muted rounded-md"> - {biddingDetail.incotermsResponse} - </div> - </div> - - <div> - <Label className="text-sm font-medium">제안 계약납기일</Label> - <div className="mt-1 p-3 bg-muted rounded-md"> - {biddingDetail.proposedContractDeliveryDate ? formatDate(biddingDetail.proposedContractDeliveryDate, 'KR') : '미설정'} - </div> - </div> - - <div> - <Label className="text-sm font-medium">제안 선적지</Label> - <div className="mt-1 p-3 bg-muted rounded-md"> - {biddingDetail.proposedShippingPort} - </div> - </div> - - <div> - <Label className="text-sm font-medium">제안 도착지</Label> - <div className="mt-1 p-3 bg-muted rounded-md"> - {biddingDetail.proposedDestinationPort} - </div> - </div> - - <div> - <Label className="text-sm font-medium">스페어파트 응답</Label> - <div className="mt-1 p-3 bg-muted rounded-md"> - {biddingDetail.sparePartResponse} - </div> - </div> - - <div> - <Label className="text-sm font-medium">연동제 적용</Label> - <div className="mt-1 p-3 bg-muted rounded-md"> - {biddingDetail.priceAdjustmentResponse ? '적용' : '미적용'} - </div> - </div> + <div className="text-center py-8"> + <XCircle className="w-16 h-16 text-destructive mx-auto mb-4" /> + <h3 className="text-lg font-semibold text-destructive mb-2">입찰에 참여하지 않기로 결정했습니다</h3> + <p className="text-muted-foreground"> + 해당 입찰에 대한 견적 제출 및 관련 기능은 이용할 수 없습니다. + </p> </div> </CardContent> </Card> - - {/* 응찰 폼 섹션 */} + ) : biddingDetail.isBiddingParticipated === true || biddingDetail.isBiddingParticipated === null ? ( + /* 응찰 폼 섹션 */ <Card> <CardHeader> <CardTitle className="flex items-center gap-2"> @@ -447,19 +587,19 @@ export function PartnersBiddingDetail({ biddingId, companyId }: PartnersBiddingD </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="finalQuoteAmount">견적금액 *</Label> + {/* 품목별 견적 섹션 */} + {/* <div className="space-y-2"> + <Label htmlFor="finalQuoteAmount">총 견적금액 *</Label> <Input id="finalQuoteAmount" type="number" value={responseData.finalQuoteAmount} onChange={(e) => setResponseData({...responseData, finalQuoteAmount: e.target.value})} - placeholder="견적금액을 입력하세요" + placeholder="총 견적금액을 입력하세요" /> - </div> + </div> */} - <div className="space-y-2"> + {/* <div className="space-y-2"> <Label htmlFor="proposedContractDeliveryDate">제안 납품일</Label> <Input id="proposedContractDeliveryDate" @@ -467,289 +607,68 @@ export function PartnersBiddingDetail({ biddingId, companyId }: PartnersBiddingD value={responseData.proposedContractDeliveryDate} onChange={(e) => setResponseData({...responseData, proposedContractDeliveryDate: e.target.value})} /> + </div> */} + + {/* 품목별 상세 견적 테이블 */} + {prItems.length > 0 ? ( + <PrItemsPricingTable + prItems={prItems} + initialQuotations={prItemQuotations} + currency={biddingDetail?.currency || 'KRW'} + onQuotationsChange={handleQuotationsChange} + onTotalAmountChange={handleTotalAmountChange} + readOnly={false} + /> + ) : ( + <div className="border rounded-lg p-4 bg-muted/20"> + <p className="text-sm text-muted-foreground text-center py-4"> + 등록된 품목이 없습니다. + </p> </div> - </div> - - <div className="grid grid-cols-1 md:grid-cols-2 gap-6"> - <div className="space-y-2"> - <Label htmlFor="paymentTermsResponse">응답 지급조건</Label> - <Input - id="paymentTermsResponse" - value={responseData.paymentTermsResponse} - onChange={(e) => setResponseData({...responseData, paymentTermsResponse: e.target.value})} - placeholder="지급조건에 대한 의견을 입력하세요" - /> - </div> - - <div className="space-y-2"> - <Label htmlFor="taxConditionsResponse">응답 세금조건</Label> - <Input - id="taxConditionsResponse" - value={responseData.taxConditionsResponse} - onChange={(e) => setResponseData({...responseData, taxConditionsResponse: e.target.value})} - placeholder="세금조건에 대한 의견을 입력하세요" - /> - </div> - </div> - - <div className="grid grid-cols-1 md:grid-cols-2 gap-6"> - <div className="space-y-2"> - <Label htmlFor="incotermsResponse">응답 운송조건</Label> - <Input - id="incotermsResponse" - value={responseData.incotermsResponse} - onChange={(e) => setResponseData({...responseData, incotermsResponse: e.target.value})} - placeholder="운송조건에 대한 의견을 입력하세요" - /> - </div> - - <div className="space-y-2"> - <Label htmlFor="proposedShippingPort">제안 선적지</Label> - <Input - id="proposedShippingPort" - value={responseData.proposedShippingPort} - onChange={(e) => setResponseData({...responseData, proposedShippingPort: e.target.value})} - placeholder="선적지를 입력하세요" - /> - </div> - </div> - - <div className="grid grid-cols-1 md:grid-cols-2 gap-6"> - <div className="space-y-2"> - <Label htmlFor="proposedDestinationPort">제안 도착지</Label> - <Input - id="proposedDestinationPort" - value={responseData.proposedDestinationPort} - onChange={(e) => setResponseData({...responseData, proposedDestinationPort: e.target.value})} - placeholder="도착지를 입력하세요" - /> - </div> + )} - <div className="space-y-2"> - <Label htmlFor="sparePartResponse">스페어파트 응답</Label> - <Input - id="sparePartResponse" - value={responseData.sparePartResponse} - onChange={(e) => setResponseData({...responseData, sparePartResponse: e.target.value})} - placeholder="스페어파트 관련 응답을 입력하세요" - /> - </div> - </div> + {/* 견적 첨부파일 섹션 */} + {biddingDetail && userId && ( + <SimpleFileUpload + biddingId={biddingId} + companyId={companyId} + userId={userId} + readOnly={false} + /> + )} - <div className="space-y-2"> - <Label htmlFor="additionalProposals">추가 제안사항</Label> + {/* 기타 사항 */} + {/* <div className="space-y-2"> + <Label htmlFor="additionalProposals">기타 사항</Label> <Textarea id="additionalProposals" value={responseData.additionalProposals} onChange={(e) => setResponseData({...responseData, additionalProposals: e.target.value})} - placeholder="추가 제안사항을 입력하세요" + 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 pt-4"> - <Button onClick={handleSubmitResponse} disabled={isPending}> + </div> */} + {/* 응찰 제출 버튼 - 미응찰 상태가 아닐 때만 표시 */} + {(biddingDetail.isBiddingParticipated === true || biddingDetail.isBiddingParticipated === null) && ( + <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} className="min-w-[100px]"> <Send className="w-4 h-4 mr-2" /> - 응찰 제출 + {isSubmitting ? '제출 중...' : '응찰 제출'} </Button> </div> + )} </CardContent> </Card> + ) : null} </div> ) } diff --git a/lib/bidding/vendor/partners-bidding-list.tsx b/lib/bidding/vendor/partners-bidding-list.tsx index 9f182911..2e8d4164 100644 --- a/lib/bidding/vendor/partners-bidding-list.tsx +++ b/lib/bidding/vendor/partners-bidding-list.tsx @@ -9,6 +9,7 @@ import type { } from '@/types/table' import { useDataTable } from '@/hooks/use-data-table' +import { useToast } from '@/hooks/use-toast' import { DataTable } from '@/components/data-table/data-table' import { DataTableAdvancedToolbar } from '@/components/data-table/data-table-advanced-toolbar' import { getPartnersBiddingListColumns } from './partners-bidding-list-columns' @@ -32,6 +33,7 @@ export function PartnersBiddingList({ companyId }: PartnersBiddingListProps) { const [selectedBiddingForPreQuoteParticipation, setSelectedBiddingForPreQuoteParticipation] = React.useState<any | null>(null) const router = useRouter() + const { toast } = useToast() // 데이터 새로고침 함수 const refreshData = React.useCallback(async () => { @@ -89,6 +91,17 @@ export function PartnersBiddingList({ companyId }: PartnersBiddingListProps) { if (rowAction) { switch (rowAction.type) { case 'view': + // 본입찰 초대 여부 확인 + const bidding = rowAction.row.original + if (bidding.status === 'bidding_opened' && !bidding.isBiddingInvited) { + // 본입찰이 오픈되었지만 초대받지 않은 경우 + toast({ + title: '접근 제한', + description: '본입찰에 초대받지 않은 업체입니다.', + variant: 'destructive', + }) + return + } // 상세 페이지로 이동 (biddingId 사용) router.push(`/partners/bid/${rowAction.row.original.biddingId}`) break diff --git a/lib/bidding/vendor/partners-bidding-pre-quote.tsx b/lib/bidding/vendor/partners-bidding-pre-quote.tsx index 94b76f58..4cd0efdb 100644 --- a/lib/bidding/vendor/partners-bidding-pre-quote.tsx +++ b/lib/bidding/vendor/partners-bidding-pre-quote.tsx @@ -746,12 +746,12 @@ export function PartnersBiddingPreQuote({ biddingId, companyId }: PartnersBiddin </div> <div className="space-y-2"> - <Label htmlFor="additionalProposals">추가 제안사항</Label> + <Label htmlFor="additionalProposals">사유</Label> <Textarea id="additionalProposals" value={responseData.additionalProposals} onChange={(e) => setResponseData({...responseData, additionalProposals: e.target.value})} - placeholder="추가 제안사항을 입력하세요" + placeholder="사유를 입력하세요" rows={4} /> </div> |
