summaryrefslogtreecommitdiff
path: root/lib
diff options
context:
space:
mode:
authordujinkim <dujin.kim@dtsolution.co.kr>2025-09-08 10:29:19 +0000
committerdujinkim <dujin.kim@dtsolution.co.kr>2025-09-08 10:29:19 +0000
commitf93493f68c9f368e10f1c3379f1c1384068e3b14 (patch)
treea9dada58741750fa7ca6e04b210443ad99a6bccc /lib
parente832a508e1b3c531fb3e1b9761e18e1b55e3d76a (diff)
(대표님, 최겸) rfqLast, bidding, prequote
Diffstat (limited to 'lib')
-rw-r--r--lib/bidding/detail/service.ts1481
-rw-r--r--lib/bidding/detail/table/bidding-award-dialog.tsx259
-rw-r--r--lib/bidding/detail/table/bidding-detail-content.tsx15
-rw-r--r--lib/bidding/detail/table/bidding-detail-selection-reason-dialog.tsx167
-rw-r--r--lib/bidding/detail/table/bidding-detail-target-price-dialog.tsx178
-rw-r--r--lib/bidding/detail/table/bidding-detail-vendor-columns.tsx171
-rw-r--r--lib/bidding/detail/table/bidding-detail-vendor-create-dialog.tsx221
-rw-r--r--lib/bidding/detail/table/bidding-detail-vendor-edit-dialog.tsx289
-rw-r--r--lib/bidding/detail/table/bidding-detail-vendor-table.tsx12
-rw-r--r--lib/bidding/detail/table/bidding-detail-vendor-toolbar-actions.tsx102
-rw-r--r--lib/bidding/detail/table/components/award-simple-file-upload.tsx307
-rw-r--r--lib/bidding/list/biddings-table-columns.tsx2
-rw-r--r--lib/bidding/list/create-bidding-dialog.tsx31
-rw-r--r--lib/bidding/pre-quote/service.ts64
-rw-r--r--lib/bidding/pre-quote/table/bidding-pre-quote-selection-dialog.tsx158
-rw-r--r--lib/bidding/pre-quote/table/bidding-pre-quote-vendor-columns.tsx4
-rw-r--r--lib/bidding/pre-quote/table/bidding-pre-quote-vendor-toolbar-actions.tsx37
-rw-r--r--lib/bidding/service.ts4
-rw-r--r--lib/bidding/vendor/components/pr-items-pricing-table.tsx23
-rw-r--r--lib/bidding/vendor/partners-bidding-detail.tsx821
-rw-r--r--lib/bidding/vendor/partners-bidding-list.tsx13
-rw-r--r--lib/bidding/vendor/partners-bidding-pre-quote.tsx4
-rw-r--r--lib/mail/templates/bidding-disposal.hbs55
-rw-r--r--lib/mail/templates/bidding-invitation.hbs63
-rw-r--r--lib/mail/templates/rebidding-invitation.hbs66
-rw-r--r--lib/rfq-last/service.ts480
-rw-r--r--lib/rfq-last/vendor/batch-update-conditions-dialog.tsx81
-rw-r--r--lib/rfq-last/vendor/rfq-vendor-table.tsx208
-rw-r--r--lib/rfq-last/vendor/send-rfq-dialog.tsx578
-rw-r--r--lib/vendor-regular-registrations/table/vendor-regular-registrations-table-columns.tsx4
30 files changed, 4270 insertions, 1628 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>
diff --git a/lib/mail/templates/bidding-disposal.hbs b/lib/mail/templates/bidding-disposal.hbs
new file mode 100644
index 00000000..5e8f16ee
--- /dev/null
+++ b/lib/mail/templates/bidding-disposal.hbs
@@ -0,0 +1,55 @@
+<!DOCTYPE html>
+<html>
+<head>
+ <meta charset="UTF-8">
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
+ <title>유찰 안내</title>
+ <style>
+ body { font-family: Arial, sans-serif; line-height: 1.6; color: #333; }
+ .container { max-width: 600px; margin: 0 auto; padding: 20px; }
+ .header { background-color: #f8f9fa; padding: 20px; border-radius: 8px; margin-bottom: 20px; }
+ .content { margin: 20px 0; }
+ .highlight { background-color: #f8d7da; padding: 15px; border-left: 4px solid #dc3545; margin: 15px 0; }
+ .footer { margin-top: 30px; padding-top: 20px; border-top: 1px solid #eee; font-size: 12px; color: #666; }
+ </style>
+</head>
+<body>
+ <div class="container">
+ <div class="header">
+ <h1>유찰 안내</h1>
+ <p><strong>입찰 번호:</strong> {{biddingNumber}}</p>
+ <p><strong>입찰명:</strong> {{title}}</p>
+ </div>
+
+ <div class="content">
+ <p>안녕하세요, {{companyName}} 담당자님</p>
+
+ <p>다음 입찰에 대하여 유찰 처리되었음을 안내드립니다.</p>
+
+ <div class="highlight">
+ <h3>유찰 입찰 정보</h3>
+ <ul>
+ <li><strong>프로젝트:</strong> {{projectName}}</li>
+ <li><strong>품목:</strong> {{itemName}}</li>
+ <li><strong>입찰 유형:</strong> {{biddingType}}</li>
+ <li><strong>처리일:</strong> {{processedDate}}</li>
+ </ul>
+ </div>
+
+ {{#if reason}}
+ <p><strong>유찰 사유:</strong></p>
+ <p>{{reason}}</p>
+ {{/if}}
+
+ <p>향후 재입찰이 진행될 경우 별도 안내를 드릴 예정입니다.</p>
+
+ <p>입찰에 참여해 주셔서 감사합니다.</p>
+ </div>
+
+ <div class="footer">
+ <p><strong>담당자:</strong> {{managerName}} ({{managerEmail}}, {{managerPhone}})</p>
+ <p>본 메일은 시스템에서 자동 발송된 메일입니다.</p>
+ </div>
+ </div>
+</body>
+</html>
diff --git a/lib/mail/templates/bidding-invitation.hbs b/lib/mail/templates/bidding-invitation.hbs
new file mode 100644
index 00000000..544fc446
--- /dev/null
+++ b/lib/mail/templates/bidding-invitation.hbs
@@ -0,0 +1,63 @@
+<!DOCTYPE html>
+<html>
+<head>
+ <meta charset="UTF-8">
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
+ <title>본입찰 초대</title>
+ <style>
+ body { font-family: Arial, sans-serif; line-height: 1.6; color: #333; }
+ .container { max-width: 600px; margin: 0 auto; padding: 20px; }
+ .header { background-color: #f8f9fa; padding: 20px; border-radius: 8px; margin-bottom: 20px; }
+ .content { margin: 20px 0; }
+ .highlight { background-color: #d4edda; padding: 15px; border-left: 4px solid #28a745; margin: 15px 0; }
+ .footer { margin-top: 30px; padding-top: 20px; border-top: 1px solid #eee; font-size: 12px; color: #666; }
+ .button { background-color: #28a745; color: white; padding: 12px 24px; text-decoration: none; border-radius: 4px; display: inline-block; margin: 10px 0; }
+ </style>
+</head>
+<body>
+ <div class="container">
+ <div class="header">
+ <h1>본입찰 초대</h1>
+ <p><strong>입찰 번호:</strong> {{biddingNumber}}</p>
+ <p><strong>입찰명:</strong> {{title}}</p>
+ </div>
+
+ <div class="content">
+ <p>안녕하세요, {{companyName}} 담당자님</p>
+
+ <p>사전견적에 참여해 주셔서 감사합니다. 귀하의 업체가 본입찰 대상업체로 선정되어 본입찰에 초대드립니다.</p>
+
+ <div class="highlight">
+ <h3>본입찰 정보</h3>
+ <ul>
+ <li><strong>프로젝트:</strong> {{projectName}}</li>
+ <li><strong>품목:</strong> {{itemName}}</li>
+ <li><strong>입찰 유형:</strong> {{biddingType}}</li>
+ </ul>
+ </div>
+
+ {{#if submissionStartDate}}
+ <p><strong>제출 기간:</strong> {{submissionStartDate}} ~ {{submissionEndDate}}</p>
+ {{/if}}
+
+ <p>본입찰은 사전견적 때와 달리 당사가 제안한 입찰 조건에 따라 응찰 여부만 결정하시면 됩니다. 품목별 견적 가격 작성과 견적서 첨부는 기존과 동일합니다.</p>
+
+ <p>본입찰 참여를 원하시는 경우, 아래 링크를 통해 시스템에 접속하여 견적을 제출해 주시기 바랍니다.</p>
+
+ <a href="{{biddingUrl}}" class="button">본입찰 참여하기</a>
+
+ <p><strong>주의사항:</strong></p>
+ <ul>
+ <li>본입찰은 선정된 업체만 참여 가능합니다.</li>
+ <li>제출 기한을 반드시 준수해 주시기 바랍니다.</li>
+ <li>궁금한 사항이 있으시면 담당자에게 연락해 주시기 바랍니다.</li>
+ </ul>
+ </div>
+
+ <div class="footer">
+ <p><strong>담당자:</strong> {{managerName}} ({{managerEmail}}, {{managerPhone}})</p>
+ <p>본 메일은 시스템에서 자동 발송된 메일입니다.</p>
+ </div>
+ </div>
+</body>
+</html>
diff --git a/lib/mail/templates/rebidding-invitation.hbs b/lib/mail/templates/rebidding-invitation.hbs
new file mode 100644
index 00000000..d57e33c5
--- /dev/null
+++ b/lib/mail/templates/rebidding-invitation.hbs
@@ -0,0 +1,66 @@
+<!DOCTYPE html>
+<html>
+<head>
+ <meta charset="UTF-8">
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
+ <title>재입찰 안내</title>
+ <style>
+ body { font-family: Arial, sans-serif; line-height: 1.6; color: #333; }
+ .container { max-width: 600px; margin: 0 auto; padding: 20px; }
+ .header { background-color: #f8f9fa; padding: 20px; border-radius: 8px; margin-bottom: 20px; }
+ .content { margin: 20px 0; }
+ .highlight { background-color: #fff3cd; padding: 15px; border-left: 4px solid #ffc107; margin: 15px 0; }
+ .footer { margin-top: 30px; padding-top: 20px; border-top: 1px solid #eee; font-size: 12px; color: #666; }
+ .button { background-color: #007bff; color: white; padding: 12px 24px; text-decoration: none; border-radius: 4px; display: inline-block; margin: 10px 0; }
+ </style>
+</head>
+<body>
+ <div class="container">
+ <div class="header">
+ <h1>재입찰 안내</h1>
+ <p><strong>입찰 번호:</strong> {{biddingNumber}}</p>
+ <p><strong>입찰명:</strong> {{title}}</p>
+ </div>
+
+ <div class="content">
+ <p>안녕하세요, {{companyName}} 담당자님</p>
+
+ <p>다음 입찰에 대하여 재입찰을 실시하게 되었음을 안내드립니다.</p>
+
+ <div class="highlight">
+ <h3>재입찰 정보</h3>
+ <ul>
+ <li><strong>프로젝트:</strong> {{projectName}}</li>
+ <li><strong>품목:</strong> {{itemName}}</li>
+ <li><strong>입찰 유형:</strong> {{biddingType}}</li>
+ <li><strong>리비전:</strong> Rev.{{revision}}</li>
+ </ul>
+ </div>
+
+ {{#if submissionStartDate}}
+ <p><strong>제출 기간:</strong> {{submissionStartDate}} ~ {{submissionEndDate}}</p>
+ {{/if}}
+
+ {{#if specificationMeeting}}
+ <div class="highlight">
+ <h3>사양설명회 정보</h3>
+ <p><strong>일시:</strong> {{specificationMeeting.meetingDate}}</p>
+ <p><strong>장소:</strong> {{specificationMeeting.location}}</p>
+ <p><strong>담당자:</strong> {{specificationMeeting.contactPerson}}</p>
+ </div>
+ {{/if}}
+
+ <p>재입찰 참여를 원하시는 경우, 아래 링크를 통해 시스템에 접속하여 견적을 제출해 주시기 바랍니다.</p>
+
+ <a href="{{biddingUrl}}" class="button">재입찰 참여하기</a>
+
+ <p>궁금한 사항이 있으시면 담당자에게 연락해 주시기 바랍니다.</p>
+ </div>
+
+ <div class="footer">
+ <p><strong>담당자:</strong> {{managerName}} ({{managerEmail}}, {{managerPhone}})</p>
+ <p>본 메일은 시스템에서 자동 발송된 메일입니다.</p>
+ </div>
+ </div>
+</body>
+</html>
diff --git a/lib/rfq-last/service.ts b/lib/rfq-last/service.ts
index 67cb901f..0c75e72f 100644
--- a/lib/rfq-last/service.ts
+++ b/lib/rfq-last/service.ts
@@ -571,9 +571,9 @@ export async function getRfqItemsAction(rfqId: number) {
materialDescription: item.materialDescription,
size: item.size,
deliveryDate: item.deliveryDate,
- quantity: item.quantity,
+ quantity: Number(item.quantity) || 0, // 여기서 숫자로 변환
uom: item.uom,
- grossWeight: item.grossWeight,
+ grossWeight: Number(item.grossWeight) || 0, // 여기서 숫자로 변환
gwUom: item.gwUom,
specNo: item.specNo,
specUrl: item.specUrl,
@@ -1835,4 +1835,480 @@ export async function getRfqWithDetails(rfqId: number) {
console.error("Get RFQ with details error:", error);
return { success: false, error: "데이터 조회 중 오류가 발생했습니다." };
}
+}
+
+
+// RFQ 정보 타입
+export interface RfqFullInfo {
+ // 기본 RFQ 정보
+ id: number;
+ rfqCode: string;
+ rfqType: string | null;
+ rfqTitle: string | null;
+ series: string | null;
+ rfqSealedYn: boolean | null;
+
+ // ITB 관련
+ projectCompany: string | null;
+ projectFlag: string | null;
+ projectSite: string | null;
+ smCode: string | null;
+
+ // RFQ 추가 필드
+ prNumber: string | null;
+ prIssueDate: Date | null;
+
+ // 프로젝트 정보
+ projectId: number | null;
+ projectCode: string | null;
+ projectName: string | null;
+
+ // 아이템 정보
+ itemCode: string | null;
+ itemName: string | null;
+
+ // 패키지 정보
+ packageNo: string | null;
+ packageName: string | null;
+
+ // 날짜 정보
+ dueDate: Date | null;
+ rfqSendDate: Date | null;
+
+ // 상태
+ status: string;
+
+ // 담당자 정보
+ picId: number | null;
+ picCode: string | null;
+ picName: string | null;
+ picUserName: string | null;
+ picTeam: string | null;
+
+ // 설계담당자
+ engPicName: string | null;
+ designTeam: string | null;
+
+ // 자재그룹 정보 (PR Items에서)
+ materialGroup: string | null;
+ materialGroupDesc: string | null;
+
+ // 카운트 정보
+ vendorCount: number;
+ shortListedVendorCount: number;
+ quotationReceivedCount: number;
+ prItemsCount: number;
+ majorItemsCount: number;
+
+ // 감사 정보
+ createdBy: number;
+ createdByUserName: string | null;
+ createdAt: Date;
+ updatedBy: number;
+ updatedByUserName: string | null;
+ updatedAt: Date;
+
+ sentBy: number | null;
+ sentByUserName: string | null;
+
+ remark: string | null;
+
+ // 평가 적용 여부 (추가 필드)
+ evaluationApply?: boolean;
+ quotationType?: string;
+ contractType?: string;
+
+ // 연관 데이터
+ vendors: VendorDetail[];
+ attachments: AttachmentInfo[];
+}
+
+// 벤더 상세 정보
+export interface VendorDetail {
+ detailId: number;
+ vendorId: number | null;
+ vendorName: string | null;
+ vendorCode: string | null;
+ vendorCountry: string | null;
+ vendorEmail?: string | null;
+ vendorCategory?: string | null;
+ vendorGrade?: string | null;
+ basicContract?: string | null;
+
+ // RFQ 조건
+ currency: string | null;
+ paymentTermsCode: string | null;
+ paymentTermsDescription: string | null;
+ incotermsCode: string | null;
+ incotermsDescription: string | null;
+ incotermsDetail: string | null;
+ deliveryDate: Date | null;
+ contractDuration: string | null;
+ taxCode: string | null;
+ placeOfShipping: string | null;
+ placeOfDestination: string | null;
+
+ // 상태
+ shortList: boolean;
+ returnYn: boolean;
+ returnedAt: Date | null;
+
+ // GTC/NDA
+ prjectGtcYn: boolean;
+ generalGtcYn: boolean;
+ ndaYn: boolean;
+ agreementYn: boolean;
+
+ // 추가 조건
+ materialPriceRelatedYn: boolean | null;
+ sparepartYn: boolean | null;
+ firstYn: boolean | null;
+ firstDescription: string | null;
+ sparepartDescription: string | null;
+
+ remark: string | null;
+ cancelReason: string | null;
+
+ // 회신 상태
+ quotationStatus?: string | null;
+ quotationSubmittedAt?: Date | null;
+
+ // 업데이트 정보
+ updatedBy: number;
+ updatedByUserName: string | null;
+ updatedAt: Date | null;
+}
+
+// 첨부파일 정보
+export interface AttachmentInfo {
+ id: number;
+ attachmentType: string;
+ serialNo: string;
+ currentRevision: string;
+ description: string | null;
+
+ // 최신 리비전 정보
+ fileName: string | null;
+ originalFileName: string | null;
+ filePath: string | null;
+ fileSize: number | null;
+ fileType: string | null;
+
+ createdBy: number;
+ createdByUserName: string | null;
+ createdAt: Date;
+ updatedAt: Date;
+}
+
+/**
+ * RFQ 전체 정보 조회
+ */
+export async function getRfqFullInfo(rfqId: number): Promise<RfqFullInfo> {
+ try {
+ // 1. RFQ 기본 정보 조회
+ const rfqData = await db
+ .select({
+ rfq: rfqsLast,
+ picUser: users,
+ })
+ .from(rfqsLast)
+ .leftJoin(users, eq(rfqsLast.pic, users.id))
+ .where(eq(rfqsLast.id, rfqId))
+ .limit(1);
+
+ if (!rfqData.length) {
+ throw new Error(`RFQ ID ${rfqId}를 찾을 수 없습니다.`);
+ }
+
+ const rfq = rfqData[0].rfq;
+ const picUser = rfqData[0].picUser;
+
+ // 2. PR Items에서 자재그룹 정보 조회 (Major Item)
+ const prItemsData = await db
+ .select({
+ materialCategory: rfqPrItems.materialCategory,
+ materialDescription: rfqPrItems.materialDescription,
+ prItemsCount: eq(rfqPrItems.majorYn, true),
+ })
+ .from(rfqPrItems)
+ .where(and(
+ eq(rfqPrItems.rfqsLastId, rfqId),
+ eq(rfqPrItems.majorYn, true)
+ ))
+ .limit(1);
+
+ const majorItem = prItemsData[0];
+
+ // 3. 벤더 정보 조회
+ const vendorsData = await db
+ .select({
+ detail: rfqLastDetails,
+ vendor: vendors,
+ paymentTerms: paymentTerms,
+ incoterms: incoterms,
+ updatedByUser: users,
+ })
+ .from(rfqLastDetails)
+ .leftJoin(vendors, eq(rfqLastDetails.vendorsId, vendors.id))
+ .leftJoin(paymentTerms, eq(rfqLastDetails.paymentTermsCode, paymentTerms.code))
+ .leftJoin(incoterms, eq(rfqLastDetails.incotermsCode, incoterms.code))
+ .leftJoin(users, eq(rfqLastDetails.updatedBy, users.id))
+ .where(eq(rfqLastDetails.rfqsLastId, rfqId));
+
+ const vendorDetails: VendorDetail[] = vendorsData.map(v => ({
+ detailId: v.detail.id,
+ vendorId: v.vendor?.id ?? null,
+ vendorName: v.vendor?.vendorName ?? null,
+ vendorCode: v.vendor?.vendorCode ?? null,
+ vendorCountry: v.vendor?.country ?? null,
+ vendorEmail: v.vendor?.email ?? null,
+ vendorCategory: v.vendor?.vendorCategory ?? null,
+ vendorGrade: v.vendor?.vendorGrade ?? null,
+ basicContract: v.vendor?.basicContract ?? null,
+
+ currency: v.detail.currency,
+ paymentTermsCode: v.detail.paymentTermsCode,
+ paymentTermsDescription: v.paymentTerms?.description ?? null,
+ incotermsCode: v.detail.incotermsCode,
+ incotermsDescription: v.incoterms?.description ?? null,
+ incotermsDetail: v.detail.incotermsDetail,
+ deliveryDate: v.detail.deliveryDate,
+ contractDuration: v.detail.contractDuration,
+ taxCode: v.detail.taxCode,
+ placeOfShipping: v.detail.placeOfShipping,
+ placeOfDestination: v.detail.placeOfDestination,
+
+ shortList: v.detail.shortList,
+ returnYn: v.detail.returnYn,
+ returnedAt: v.detail.returnedAt,
+
+ prjectGtcYn: v.detail.prjectGtcYn,
+ generalGtcYn: v.detail.generalGtcYn,
+ ndaYn: v.detail.ndaYn,
+ agreementYn: v.detail.agreementYn,
+
+ materialPriceRelatedYn: v.detail.materialPriceRelatedYn,
+ sparepartYn: v.detail.sparepartYn,
+ firstYn: v.detail.firstYn,
+ firstDescription: v.detail.firstDescription,
+ sparepartDescription: v.detail.sparepartDescription,
+
+ remark: v.detail.remark,
+ cancelReason: v.detail.cancelReason,
+
+ updatedBy: v.detail.updatedBy,
+ updatedByUserName: v.updatedByUser?.name ?? null,
+ updatedAt: v.detail.updatedAt,
+ }));
+
+ // 4. 첨부파일 정보 조회
+ const attachmentsData = await db
+ .select({
+ attachment: rfqLastAttachments,
+ revision: rfqLastAttachmentRevisions,
+ createdByUser: users,
+ })
+ .from(rfqLastAttachments)
+ .leftJoin(
+ rfqLastAttachmentRevisions,
+ and(
+ eq(rfqLastAttachments.latestRevisionId, rfqLastAttachmentRevisions.id),
+ eq(rfqLastAttachmentRevisions.isLatest, true)
+ )
+ )
+ .leftJoin(users, eq(rfqLastAttachments.createdBy, users.id))
+ .where(eq(rfqLastAttachments.rfqId, rfqId));
+
+ const attachments: AttachmentInfo[] = attachmentsData.map(a => ({
+ id: a.attachment.id,
+ attachmentType: a.attachment.attachmentType,
+ serialNo: a.attachment.serialNo,
+ currentRevision: a.attachment.currentRevision,
+ description: a.attachment.description,
+
+ fileName: a.revision?.fileName ?? null,
+ originalFileName: a.revision?.originalFileName ?? null,
+ filePath: a.revision?.filePath ?? null,
+ fileSize: a.revision?.fileSize ?? null,
+ fileType: a.revision?.fileType ?? null,
+
+ createdBy: a.attachment.createdBy,
+ createdByUserName: a.createdByUser?.name ?? null,
+ createdAt: a.attachment.createdAt,
+ updatedAt: a.attachment.updatedAt,
+ }));
+
+ // 5. 카운트 정보 계산
+ const vendorCount = vendorDetails.length;
+ const shortListedVendorCount = vendorDetails.filter(v => v.shortList).length;
+ const quotationReceivedCount = vendorDetails.filter(v => v.quotationSubmittedAt).length;
+
+ // PR Items 카운트 (별도 쿼리 필요)
+ const prItemsCount = await db
+ .select({ count: sql<number>`COUNT(*)` })
+ .from(rfqPrItems)
+ .where(eq(rfqPrItems.rfqsLastId, rfqId));
+
+ const majorItemsCount = await db
+ .select({ count: sql<number>`COUNT(*)` })
+ .from(rfqPrItems)
+ .where(and(
+ eq(rfqPrItems.rfqsLastId, rfqId),
+ eq(rfqPrItems.majorYn, true)
+ ));
+
+ // 6. 사용자 정보 조회 (createdBy, updatedBy, sentBy)
+ const [createdByUser] = await db
+ .select({ name: users.name })
+ .from(users)
+ .where(eq(users.id, rfq.createdBy))
+ .limit(1);
+
+ const [updatedByUser] = await db
+ .select({ name: users.name })
+ .from(users)
+ .where(eq(users.id, rfq.updatedBy))
+ .limit(1);
+
+ const [sentByUser] = rfq.sentBy
+ ? await db
+ .select({ name: users.name })
+ .from(users)
+ .where(eq(users.id, rfq.sentBy))
+ .limit(1)
+ : [null];
+
+ // 7. 전체 정보 조합
+ const rfqFullInfo: RfqFullInfo = {
+ // 기본 정보
+ id: rfq.id,
+ rfqCode: rfq.rfqCode ?? '',
+ rfqType: rfq.rfqType,
+ rfqTitle: rfq.rfqTitle,
+ series: rfq.series,
+ rfqSealedYn: rfq.rfqSealedYn,
+
+ // ITB 관련
+ projectCompany: rfq.projectCompany,
+ projectFlag: rfq.projectFlag,
+ projectSite: rfq.projectSite,
+ smCode: rfq.smCode,
+
+ // RFQ 추가 필드
+ prNumber: rfq.prNumber,
+ prIssueDate: rfq.prIssueDate,
+
+ // 프로젝트
+ projectId: rfq.projectId,
+ projectCode: null, // 프로젝트 조인 필요시 추가
+ projectName: null, // 프로젝트 조인 필요시 추가
+
+ // 아이템
+ itemCode: rfq.itemCode,
+ itemName: rfq.itemName,
+
+ // 패키지
+ packageNo: rfq.packageNo,
+ packageName: rfq.packageName,
+
+ // 날짜
+ dueDate: rfq.dueDate,
+ rfqSendDate: rfq.rfqSendDate,
+
+ // 상태
+ status: rfq.status,
+
+ // 구매 담당자
+ picId: rfq.pic,
+ picCode: rfq.picCode,
+ picName: rfq.picName,
+ picUserName: picUser?.name ?? null,
+ picTeam: picUser?.department ?? null, // users 테이블에 department 필드가 있다고 가정
+
+ // 설계 담당자
+ engPicName: rfq.EngPicName,
+ designTeam: null, // 추가 정보 필요시 입력
+
+ // 자재그룹 (PR Items에서)
+ materialGroup: majorItem?.materialCategory ?? null,
+ materialGroupDesc: majorItem?.materialDescription ?? null,
+
+ // 카운트
+ vendorCount,
+ shortListedVendorCount,
+ quotationReceivedCount,
+ prItemsCount: prItemsCount[0]?.count ?? 0,
+ majorItemsCount: majorItemsCount[0]?.count ?? 0,
+
+ // 감사 정보
+ createdBy: rfq.createdBy,
+ createdByUserName: createdByUser?.name ?? null,
+ createdAt: rfq.createdAt,
+ updatedBy: rfq.updatedBy,
+ updatedByUserName: updatedByUser?.name ?? null,
+ updatedAt: rfq.updatedAt,
+ sentBy: rfq.sentBy,
+ sentByUserName: sentByUser?.name ?? null,
+
+ remark: rfq.remark,
+
+ // 추가 필드 (필요시)
+ evaluationApply: true, // 기본값 또는 별도 로직
+ quotationType: rfq.rfqType ?? undefined,
+ contractType: undefined, // 별도 필드 필요
+
+ // 연관 데이터
+ vendors: vendorDetails,
+ attachments: attachments,
+ };
+
+ return rfqFullInfo;
+ } catch (error) {
+ console.error("RFQ 정보 조회 실패:", error);
+ throw error;
+ }
+}
+
+/**
+ * SendRfqDialog용 간단한 정보 조회
+ */
+export async function getRfqInfoForSend(rfqId: number) {
+ const fullInfo = await getRfqFullInfo(rfqId);
+
+ return {
+ rfqCode: fullInfo.rfqCode,
+ rfqTitle: fullInfo.rfqTitle || '',
+ rfqType: fullInfo.rfqType || '',
+ projectCode: fullInfo.projectCode,
+ projectName: fullInfo.projectName,
+ picName: fullInfo.picName,
+ picCode: fullInfo.picCode,
+ picTeam: fullInfo.picTeam,
+ packageNo: fullInfo.packageNo,
+ packageName: fullInfo.packageName,
+ designPicName: fullInfo.engPicName, // EngPicName이 설계담당자
+ designTeam: fullInfo.designTeam,
+ materialGroup: fullInfo.materialGroup,
+ materialGroupDesc: fullInfo.materialGroupDesc,
+ dueDate: fullInfo.dueDate || new Date(),
+ quotationType: fullInfo.quotationType,
+ evaluationApply: fullInfo.evaluationApply,
+ contractType: fullInfo.contractType,
+ };
+}
+
+/**
+ * 벤더 정보만 조회
+ */
+export async function getRfqVendors(rfqId: number) {
+ const fullInfo = await getRfqFullInfo(rfqId);
+ return fullInfo.vendors;
+}
+
+/**
+ * 첨부파일 정보만 조회
+ */
+export async function getRfqAttachments(rfqId: number) {
+ const fullInfo = await getRfqFullInfo(rfqId);
+ return fullInfo.attachments;
} \ No newline at end of file
diff --git a/lib/rfq-last/vendor/batch-update-conditions-dialog.tsx b/lib/rfq-last/vendor/batch-update-conditions-dialog.tsx
index 1b8fa528..7de8cfa4 100644
--- a/lib/rfq-last/vendor/batch-update-conditions-dialog.tsx
+++ b/lib/rfq-last/vendor/batch-update-conditions-dialog.tsx
@@ -50,11 +50,11 @@ import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/com
import { ScrollArea } from "@/components/ui/scroll-area";
import { Alert, AlertDescription } from "@/components/ui/alert";
import { Checkbox } from "@/components/ui/checkbox";
-import {
+import {
getIncotermsForSelection,
getPaymentTermsForSelection,
getPlaceOfShippingForSelection,
- getPlaceOfDestinationForSelection
+ getPlaceOfDestinationForSelection
} from "@/lib/procurement-select/service";
interface BatchUpdateConditionsDialogProps {
@@ -108,19 +108,19 @@ export function BatchUpdateConditionsDialog({
onSuccess,
}: BatchUpdateConditionsDialogProps) {
const [isLoading, setIsLoading] = React.useState(false);
-
+
// Select 옵션들 상태
const [incoterms, setIncoterms] = React.useState<SelectOption[]>([]);
const [paymentTerms, setPaymentTerms] = React.useState<SelectOption[]>([]);
const [shippingPlaces, setShippingPlaces] = React.useState<SelectOption[]>([]);
const [destinationPlaces, setDestinationPlaces] = React.useState<SelectOption[]>([]);
-
+
// 로딩 상태
const [incotermsLoading, setIncotermsLoading] = React.useState(false);
const [paymentTermsLoading, setPaymentTermsLoading] = React.useState(false);
const [shippingLoading, setShippingLoading] = React.useState(false);
const [destinationLoading, setDestinationLoading] = React.useState(false);
-
+
// Popover 열림 상태
const [incotermsOpen, setIncotermsOpen] = React.useState(false);
const [paymentTermsOpen, setPaymentTermsOpen] = React.useState(false);
@@ -254,7 +254,7 @@ export function BatchUpdateConditionsDialog({
// 선택된 필드만 포함하여 conditions 객체 생성
const conditions: any = {};
-
+
if (fieldsToUpdate.currency && data.currency) {
conditions.currency = data.currency;
}
@@ -372,7 +372,7 @@ export function BatchUpdateConditionsDialog({
<Alert>
<Info className="h-4 w-4" />
<AlertDescription>
- 체크박스를 선택한 항목만 업데이트됩니다.
+ 체크박스를 선택한 항목만 업데이트됩니다.
선택하지 않은 항목은 기존 값이 유지됩니다.
</AlertDescription>
</Alert>
@@ -387,7 +387,7 @@ export function BatchUpdateConditionsDialog({
<div className="flex items-center gap-4">
<Checkbox
checked={fieldsToUpdate.currency}
- onCheckedChange={(checked) =>
+ onCheckedChange={(checked) =>
setFieldsToUpdate({ ...fieldsToUpdate, currency: !!checked })
}
/>
@@ -419,7 +419,13 @@ export function BatchUpdateConditionsDialog({
<PopoverContent className="w-full p-0" align="start">
<Command>
<CommandInput placeholder="통화 검색..." />
- <CommandList>
+ <CommandList
+ onWheel={(e) => {
+ e.stopPropagation(); // 이벤트 전파 차단
+ const target = e.currentTarget;
+ target.scrollTop += e.deltaY; // 직접 스크롤 처리
+ }}
+ >
<CommandEmpty>검색 결과가 없습니다.</CommandEmpty>
<CommandGroup>
{currencies.map((currency) => (
@@ -454,7 +460,7 @@ export function BatchUpdateConditionsDialog({
<div className="flex items-center gap-4">
<Checkbox
checked={fieldsToUpdate.paymentTermsCode}
- onCheckedChange={(checked) =>
+ onCheckedChange={(checked) =>
setFieldsToUpdate({ ...fieldsToUpdate, paymentTermsCode: !!checked })
}
/>
@@ -496,7 +502,13 @@ export function BatchUpdateConditionsDialog({
<PopoverContent className="w-full p-0" align="start">
<Command>
<CommandInput placeholder="코드 또는 설명으로 검색..." />
- <CommandList>
+ <CommandList
+ onWheel={(e) => {
+ e.stopPropagation(); // 이벤트 전파 차단
+ const target = e.currentTarget;
+ target.scrollTop += e.deltaY; // 직접 스크롤 처리
+ }}
+ >
<CommandEmpty>검색 결과가 없습니다.</CommandEmpty>
<CommandGroup>
{paymentTerms.map((term) => (
@@ -538,7 +550,7 @@ export function BatchUpdateConditionsDialog({
<Checkbox
className="mt-3"
checked={fieldsToUpdate.incoterms}
- onCheckedChange={(checked) =>
+ onCheckedChange={(checked) =>
setFieldsToUpdate({ ...fieldsToUpdate, incoterms: !!checked })
}
/>
@@ -581,7 +593,12 @@ export function BatchUpdateConditionsDialog({
<PopoverContent className="w-full p-0" align="start">
<Command>
<CommandInput placeholder="코드 또는 설명으로 검색..." />
- <CommandList>
+ <CommandList
+ onWheel={(e) => {
+ e.stopPropagation(); // 이벤트 전파 차단
+ const target = e.currentTarget;
+ target.scrollTop += e.deltaY; // 직접 스크롤 처리
+ }}>
<CommandEmpty>검색 결과가 없습니다.</CommandEmpty>
<CommandGroup>
{incoterms.map((incoterm) => (
@@ -640,7 +657,7 @@ export function BatchUpdateConditionsDialog({
<div className="flex items-center gap-4">
<Checkbox
checked={fieldsToUpdate.deliveryDate}
- onCheckedChange={(checked) =>
+ onCheckedChange={(checked) =>
setFieldsToUpdate({ ...fieldsToUpdate, deliveryDate: !!checked })
}
/>
@@ -701,7 +718,7 @@ export function BatchUpdateConditionsDialog({
<div className="flex items-center gap-4">
<Checkbox
checked={fieldsToUpdate.contractDuration}
- onCheckedChange={(checked) =>
+ onCheckedChange={(checked) =>
setFieldsToUpdate({ ...fieldsToUpdate, contractDuration: !!checked })
}
/>
@@ -736,7 +753,7 @@ export function BatchUpdateConditionsDialog({
<div className="flex items-center gap-4">
<Checkbox
checked={fieldsToUpdate.taxCode}
- onCheckedChange={(checked) =>
+ onCheckedChange={(checked) =>
setFieldsToUpdate({ ...fieldsToUpdate, taxCode: !!checked })
}
/>
@@ -770,7 +787,7 @@ export function BatchUpdateConditionsDialog({
<Checkbox
className="mt-3"
checked={fieldsToUpdate.shipping}
- onCheckedChange={(checked) =>
+ onCheckedChange={(checked) =>
setFieldsToUpdate({ ...fieldsToUpdate, shipping: !!checked })
}
/>
@@ -813,7 +830,13 @@ export function BatchUpdateConditionsDialog({
<PopoverContent className="w-full p-0" align="start">
<Command>
<CommandInput placeholder="선적지 검색..." />
- <CommandList>
+ <CommandList
+ onWheel={(e) => {
+ e.stopPropagation(); // 이벤트 전파 차단
+ const target = e.currentTarget;
+ target.scrollTop += e.deltaY; // 직접 스크롤 처리
+ }}
+ >
<CommandEmpty>검색 결과가 없습니다.</CommandEmpty>
<CommandGroup>
{shippingPlaces.map((place) => (
@@ -848,7 +871,7 @@ export function BatchUpdateConditionsDialog({
</FormItem>
)}
/>
-
+
<FormField
control={form.control}
name="placeOfDestination"
@@ -887,7 +910,13 @@ export function BatchUpdateConditionsDialog({
<PopoverContent className="w-full p-0" align="start">
<Command>
<CommandInput placeholder="도착지 검색..." />
- <CommandList>
+ <CommandList
+ onWheel={(e) => {
+ e.stopPropagation(); // 이벤트 전파 차단
+ const target = e.currentTarget;
+ target.scrollTop += e.deltaY; // 직접 스크롤 처리
+ }}
+ >
<CommandEmpty>검색 결과가 없습니다.</CommandEmpty>
<CommandGroup>
{destinationPlaces.map((place) => (
@@ -937,7 +966,7 @@ export function BatchUpdateConditionsDialog({
<div className="flex items-center gap-4">
<Checkbox
checked={fieldsToUpdate.materialPrice}
- onCheckedChange={(checked) =>
+ onCheckedChange={(checked) =>
setFieldsToUpdate({ ...fieldsToUpdate, materialPrice: !!checked })
}
/>
@@ -973,7 +1002,7 @@ export function BatchUpdateConditionsDialog({
<div className="flex items-center gap-4">
<Checkbox
checked={fieldsToUpdate.sparepart}
- onCheckedChange={(checked) =>
+ onCheckedChange={(checked) =>
setFieldsToUpdate({ ...fieldsToUpdate, sparepart: !!checked })
}
/>
@@ -1028,7 +1057,7 @@ export function BatchUpdateConditionsDialog({
<div className="flex items-center gap-4">
<Checkbox
checked={fieldsToUpdate.first}
- onCheckedChange={(checked) =>
+ onCheckedChange={(checked) =>
setFieldsToUpdate({ ...fieldsToUpdate, first: !!checked })
}
/>
@@ -1086,7 +1115,7 @@ export function BatchUpdateConditionsDialog({
<DialogFooter className="p-6 pt-4 border-t">
<div className="flex items-center justify-between w-full">
<div className="text-sm text-muted-foreground">
- {getUpdateCount() > 0
+ {getUpdateCount() > 0
? `${getUpdateCount()}개 항목 선택됨`
: '변경할 항목을 선택하세요'
}
@@ -1100,12 +1129,12 @@ export function BatchUpdateConditionsDialog({
>
취소
</Button>
- <Button
+ <Button
type="submit"
disabled={isLoading || getUpdateCount() === 0}
>
{isLoading && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
- {getUpdateCount() > 0
+ {getUpdateCount() > 0
? `${getUpdateCount()}개 항목 업데이트`
: '조건 업데이트'
}
diff --git a/lib/rfq-last/vendor/rfq-vendor-table.tsx b/lib/rfq-last/vendor/rfq-vendor-table.tsx
index b6d42804..7f7afe14 100644
--- a/lib/rfq-last/vendor/rfq-vendor-table.tsx
+++ b/lib/rfq-last/vendor/rfq-vendor-table.tsx
@@ -3,7 +3,7 @@
import * as React from "react";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
-import {
+import {
Plus,
Send,
Eye,
@@ -32,7 +32,7 @@ import { type ColumnDef } from "@tanstack/react-table";
import { Checkbox } from "@/components/ui/checkbox";
import { ClientDataTableColumnHeaderSimple } from "@/components/client-data-table/data-table-column-simple-header";
import { ClientDataTable } from "@/components/client-data-table/data-table";
-import {
+import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
@@ -50,7 +50,9 @@ import { cn } from "@/lib/utils";
import { toast } from "sonner";
import { AddVendorDialog } from "./add-vendor-dialog";
import { BatchUpdateConditionsDialog } from "./batch-update-conditions-dialog";
+import { SendRfqDialog } from "./send-rfq-dialog";
// import { VendorDetailDialog } from "./vendor-detail-dialog";
+// import { sendRfqToVendors } from "@/app/actions/rfq/send-rfq.action";
// 타입 정의
interface RfqDetail {
@@ -59,9 +61,10 @@ interface RfqDetail {
vendorName: string | null;
vendorCode: string | null;
vendorCountry: string | null;
- vendorCategory?: string | null; // 업체분류
- vendorGrade?: string | null; // AVL 등급
- basicContract?: string | null; // 기본계약
+ vendorEmail?: string | null;
+ vendorCategory?: string | null;
+ vendorGrade?: string | null;
+ basicContract?: string | null;
shortList: boolean;
currency: string | null;
paymentTermsCode: string | null;
@@ -97,11 +100,42 @@ interface VendorResponse {
attachmentCount?: number;
}
+// Props 타입 정의 (중복 제거하고 하나로 통합)
interface RfqVendorTableProps {
rfqId: number;
rfqCode?: string;
rfqDetails: RfqDetail[];
vendorResponses: VendorResponse[];
+ // 추가 props
+ rfqInfo?: {
+ rfqTitle: string;
+ rfqType: string;
+ projectCode?: string;
+ projectName?: string;
+ picName?: string;
+ picCode?: string;
+ picTeam?: string;
+ packageNo?: string;
+ packageName?: string;
+ designPicName?: string;
+ designTeam?: string;
+ materialGroup?: string;
+ materialGroupDesc?: string;
+ dueDate: Date;
+ quotationType?: string;
+ evaluationApply?: boolean;
+ contractType?: string;
+ };
+ attachments?: Array<{
+ id: number;
+ attachmentType: string;
+ serialNo: string;
+ currentRevision: string;
+ description?: string;
+ fileName?: string;
+ fileSize?: number;
+ uploadedAt?: Date;
+ }>;
}
// 상태별 아이콘 반환
@@ -158,43 +192,94 @@ export function RfqVendorTable({
rfqCode,
rfqDetails,
vendorResponses,
+ rfqInfo,
+ attachments,
}: RfqVendorTableProps) {
const [isRefreshing, setIsRefreshing] = React.useState(false);
const [selectedRows, setSelectedRows] = React.useState<any[]>([]);
const [isAddDialogOpen, setIsAddDialogOpen] = React.useState(false);
const [isBatchUpdateOpen, setIsBatchUpdateOpen] = React.useState(false);
const [selectedVendor, setSelectedVendor] = React.useState<any | null>(null);
+ const [isSendDialogOpen, setIsSendDialogOpen] = React.useState(false);
// 데이터 병합
const mergedData = React.useMemo(
() => mergeVendorData(rfqDetails, vendorResponses, rfqCode),
[rfqDetails, vendorResponses, rfqCode]
);
-
+
+ // 일괄 발송 핸들러
+ const handleBulkSend = React.useCallback(async () => {
+ if (selectedRows.length === 0) {
+ toast.warning("발송할 벤더를 선택해주세요.");
+ return;
+ }
+
+ // 다이얼로그 열기
+ setIsSendDialogOpen(true);
+ }, [selectedRows]);
+
+ // RFQ 발송 핸들러
+ const handleSendRfq = React.useCallback(async (data: {
+ vendors: Array<{
+ vendorId: number;
+ vendorName: string;
+ vendorCode?: string | null;
+ vendorCountry?: string | null;
+ vendorEmail?: string | null;
+ currency?: string | null;
+ additionalRecipients: string[];
+ }>;
+ attachments: number[];
+ message?: string;
+ }) => {
+ try {
+ // 서버 액션 호출
+ // const result = await sendRfqToVendors({
+ // rfqId,
+ // rfqCode,
+ // vendors: data.vendors,
+ // attachmentIds: data.attachments,
+ // message: data.message,
+ // });
+
+ // 임시 성공 처리
+ console.log("RFQ 발송 데이터:", data);
+
+ // 성공 후 처리
+ setSelectedRows([]);
+ toast.success(`${data.vendors.length}개 업체에 RFQ를 발송했습니다.`);
+ } catch (error) {
+ console.error("RFQ 발송 실패:", error);
+ toast.error("RFQ 발송에 실패했습니다.");
+ throw error;
+ }
+ }, [rfqId, rfqCode]);
+
// 액션 처리
const handleAction = React.useCallback(async (action: string, vendor: any) => {
switch (action) {
case "view":
setSelectedVendor(vendor);
break;
-
+
case "send":
// RFQ 발송 로직
toast.info(`${vendor.vendorName}에게 RFQ를 발송합니다.`);
break;
-
+
case "edit":
// 수정 로직
toast.info("수정 기능은 준비중입니다.");
break;
-
+
case "delete":
// 삭제 로직
if (confirm(`${vendor.vendorName}을(를) 삭제하시겠습니까?`)) {
toast.success(`${vendor.vendorName}이(가) 삭제되었습니다.`);
}
break;
-
+
case "response-detail":
// 회신 상세 보기
toast.info(`${vendor.vendorName}의 회신 상세를 확인합니다.`);
@@ -202,21 +287,6 @@ export function RfqVendorTable({
}
}, []);
- // 선택된 벤더들에게 일괄 발송
- const handleBulkSend = React.useCallback(async () => {
- if (selectedRows.length === 0) {
- toast.warning("발송할 벤더를 선택해주세요.");
- return;
- }
-
- const vendorNames = selectedRows.map(r => r.vendorName).join(", ");
- if (confirm(`선택한 ${selectedRows.length}개 벤더에게 RFQ를 발송하시겠습니까?\n\n${vendorNames}`)) {
- toast.success(`${selectedRows.length}개 벤더에게 RFQ를 발송했습니다.`);
- setSelectedRows([]);
- }
- }, [selectedRows]);
-
-
// 컬럼 정의 (확장된 버전)
const columns: ColumnDef<any>[] = React.useMemo(() => [
{
@@ -251,19 +321,6 @@ export function RfqVendorTable({
},
size: 120,
},
- // {
- // accessorKey: "response.responseVersion",
- // header: ({ column }) => <ClientDataTableColumnHeaderSimple column={column} title="Rev" />,
- // cell: ({ row }) => {
- // const version = row.original.response?.responseVersion;
- // return version ? (
- // <Badge variant="outline" className="font-mono">v{version}</Badge>
- // ) : (
- // <span className="text-muted-foreground">-</span>
- // );
- // },
- // size: 60,
- // },
{
accessorKey: "vendorName",
header: ({ column }) => <ClientDataTableColumnHeaderSimple column={column} title="협력업체정보" />,
@@ -307,14 +364,14 @@ export function RfqVendorTable({
cell: ({ row }) => {
const grade = row.original.vendorGrade;
if (!grade) return <span className="text-muted-foreground">-</span>;
-
+
const gradeColor = {
"A": "text-green-600",
- "B": "text-blue-600",
+ "B": "text-blue-600",
"C": "text-yellow-600",
"D": "text-red-600",
}[grade] || "text-gray-600";
-
+
return <span className={cn("font-semibold", gradeColor)}>{grade}</span>;
},
size: 100,
@@ -373,15 +430,15 @@ export function RfqVendorTable({
cell: ({ row }) => {
const deliveryDate = row.original.deliveryDate;
const contractDuration = row.original.contractDuration;
-
+
return (
<div className="flex flex-col gap-0.5">
- {deliveryDate && (
+ {deliveryDate && !rfqCode?.startsWith("F") && (
<span className="text-xs">
{format(new Date(deliveryDate), "yyyy-MM-dd")}
</span>
)}
- {contractDuration && (
+ {contractDuration && rfqCode?.startsWith("F") && (
<span className="text-xs text-muted-foreground">{contractDuration}</span>
)}
{!deliveryDate && !contractDuration && (
@@ -398,7 +455,7 @@ export function RfqVendorTable({
cell: ({ row }) => {
const code = row.original.incotermsCode;
const detail = row.original.incotermsDetail;
-
+
return (
<TooltipProvider>
<Tooltip>
@@ -459,7 +516,7 @@ export function RfqVendorTable({
if (conditions === "-") {
return <span className="text-muted-foreground">-</span>;
}
-
+
const items = conditions.split(", ");
return (
<div className="flex flex-wrap gap-1">
@@ -479,11 +536,11 @@ export function RfqVendorTable({
cell: ({ row }) => {
const submittedAt = row.original.response?.submittedAt;
const status = row.original.response?.status;
-
+
if (!submittedAt) {
return <Badge variant="outline">미참여</Badge>;
}
-
+
return (
<div className="flex flex-col gap-0.5">
<Badge variant="default" className="text-xs">참여</Badge>
@@ -500,11 +557,11 @@ export function RfqVendorTable({
header: "회신상세",
cell: ({ row }) => {
const hasResponse = !!row.original.response?.submittedAt;
-
+
if (!hasResponse) {
return <span className="text-muted-foreground text-xs">-</span>;
}
-
+
return (
<Button
variant="ghost"
@@ -565,7 +622,7 @@ export function RfqVendorTable({
cell: ({ row }) => {
const vendor = row.original;
const hasResponse = !!vendor.response;
-
+
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
@@ -592,7 +649,7 @@ export function RfqVendorTable({
조건 수정
</DropdownMenuItem>
<DropdownMenuSeparator />
- <DropdownMenuItem
+ <DropdownMenuItem
onClick={() => handleAction("delete", vendor)}
className="text-red-600"
>
@@ -605,7 +662,7 @@ export function RfqVendorTable({
},
size: 60,
},
- ], [handleAction]);
+ ], [handleAction, rfqCode]);
const advancedFilterFields: DataTableAdvancedFilterField<any>[] = [
{ id: "vendorName", label: "벤더명", type: "text" },
@@ -644,6 +701,41 @@ export function RfqVendorTable({
}));
}, [selectedRows]);
+ // 선택된 벤더 정보 (Send용)
+ const selectedVendorsForSend = React.useMemo(() => {
+ return selectedRows.map(row => ({
+ vendorId: row.vendorId,
+ vendorName: row.vendorName,
+ vendorCode: row.vendorCode,
+ vendorCountry: row.vendorCountry,
+ vendorEmail: row.vendorEmail || `vendor${row.vendorId}@example.com`,
+ currency: row.currency,
+ }));
+ }, [selectedRows]);
+
+ // RFQ 정보 준비 (다이얼로그용)
+ const rfqInfoForDialog = React.useMemo(() => {
+ // props로 받은 rfqInfo 사용, 없으면 기본값
+ return rfqInfo || {
+ rfqCode: rfqCode || '',
+ rfqTitle: '테스트 RFQ',
+ rfqType: '정기견적',
+ projectCode: 'PN003',
+ projectName: 'PETRONAS ZLNG nearshore project',
+ picName: '김*종',
+ picCode: '86D',
+ picTeam: '해양구매팀(해양구매1)',
+ packageNo: 'MM03',
+ packageName: 'Deck Machinery',
+ designPicName: '이*진',
+ designTeam: '전장설계팀 (전장기기시스템)',
+ materialGroup: 'BE2101',
+ materialGroupDesc: 'Combined Windlass & Mooring Wi',
+ dueDate: new Date('2025-07-05'),
+ evaluationApply: true,
+ };
+ }, [rfqInfo, rfqCode]);
+
// 추가 액션 버튼들
const additionalActions = React.useMemo(() => (
<div className="flex items-center gap-2">
@@ -732,6 +824,16 @@ export function RfqVendorTable({
}}
/>
+ {/* RFQ 발송 다이얼로그 */}
+ <SendRfqDialog
+ open={isSendDialogOpen}
+ onOpenChange={setIsSendDialogOpen}
+ selectedVendors={selectedVendorsForSend}
+ rfqInfo={rfqInfoForDialog}
+ attachments={attachments || []}
+ onSend={handleSendRfq}
+ />
+
{/* 벤더 상세 다이얼로그 */}
{/* {selectedVendor && (
<VendorDetailDialog
diff --git a/lib/rfq-last/vendor/send-rfq-dialog.tsx b/lib/rfq-last/vendor/send-rfq-dialog.tsx
new file mode 100644
index 00000000..dc420cad
--- /dev/null
+++ b/lib/rfq-last/vendor/send-rfq-dialog.tsx
@@ -0,0 +1,578 @@
+"use client";
+
+import * as React from "react";
+import {
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogFooter,
+ DialogHeader,
+ DialogTitle,
+} from "@/components/ui/dialog";
+import { Button } from "@/components/ui/button";
+import { Badge } from "@/components/ui/badge";
+import { Input } from "@/components/ui/input";
+import { Label } from "@/components/ui/label";
+import { Separator } from "@/components/ui/separator";
+import { ScrollArea } from "@/components/ui/scroll-area";
+import { Checkbox } from "@/components/ui/checkbox";
+import {
+ Send,
+ Building2,
+ User,
+ Calendar,
+ Package,
+ FileText,
+ Plus,
+ X,
+ Paperclip,
+ Download,
+ Mail,
+ Users,
+ AlertCircle,
+ Info,
+ File,
+ CheckCircle,
+ RefreshCw
+} from "lucide-react";
+import { format } from "date-fns";
+import { ko } from "date-fns/locale";
+import { toast } from "sonner";
+import { cn } from "@/lib/utils";
+import {
+ Tooltip,
+ TooltipContent,
+ TooltipProvider,
+ TooltipTrigger,
+} from "@/components/ui/tooltip";
+import {
+ Alert,
+ AlertDescription,
+} from "@/components/ui/alert";
+
+// 타입 정의
+interface Vendor {
+ vendorId: number;
+ vendorName: string;
+ vendorCode?: string | null;
+ vendorCountry?: string | null;
+ vendorEmail?: string | null;
+ currency?: string | null;
+}
+
+interface Attachment {
+ id: number;
+ attachmentType: string;
+ serialNo: string;
+ currentRevision: string;
+ description?: string;
+ fileName?: string;
+ fileSize?: number;
+ uploadedAt?: Date;
+}
+
+interface RfqInfo {
+ rfqCode: string;
+ rfqTitle: string;
+ rfqType: string;
+ projectCode?: string;
+ projectName?: string;
+ picName?: string;
+ picCode?: string;
+ picTeam?: string;
+ packageNo?: string;
+ packageName?: string;
+ designPicName?: string;
+ designTeam?: string;
+ materialGroup?: string;
+ materialGroupDesc?: string;
+ dueDate: Date;
+ quotationType?: string;
+ evaluationApply?: boolean;
+ contractType?: string;
+}
+
+interface VendorWithRecipients extends Vendor {
+ additionalRecipients: string[];
+}
+
+interface SendRfqDialogProps {
+ open: boolean;
+ onOpenChange: (open: boolean) => void;
+ selectedVendors: Vendor[];
+ rfqInfo: RfqInfo;
+ attachments?: Attachment[];
+ onSend: (data: {
+ vendors: VendorWithRecipients[];
+ attachments: number[];
+ message?: string;
+ }) => Promise<void>;
+}
+
+// 첨부파일 타입별 아이콘
+const getAttachmentIcon = (type: string) => {
+ switch (type.toLowerCase()) {
+ case "technical":
+ return <FileText className="h-4 w-4 text-blue-500" />;
+ case "commercial":
+ return <File className="h-4 w-4 text-green-500" />;
+ case "drawing":
+ return <Package className="h-4 w-4 text-purple-500" />;
+ default:
+ return <Paperclip className="h-4 w-4 text-gray-500" />;
+ }
+};
+
+// 파일 크기 포맷
+const formatFileSize = (bytes?: number) => {
+ if (!bytes) return "0 KB";
+ const kb = bytes / 1024;
+ const mb = kb / 1024;
+ if (mb >= 1) return `${mb.toFixed(2)} MB`;
+ return `${kb.toFixed(2)} KB`;
+};
+
+export function SendRfqDialog({
+ open,
+ onOpenChange,
+ selectedVendors,
+ rfqInfo,
+ attachments = [],
+ onSend,
+}: SendRfqDialogProps) {
+ const [isSending, setIsSending] = React.useState(false);
+ const [vendorsWithRecipients, setVendorsWithRecipients] = React.useState<VendorWithRecipients[]>([]);
+ const [selectedAttachments, setSelectedAttachments] = React.useState<number[]>([]);
+ const [additionalMessage, setAdditionalMessage] = React.useState("");
+
+ // 초기화
+ React.useEffect(() => {
+ if (open && selectedVendors.length > 0) {
+ setVendorsWithRecipients(
+ selectedVendors.map(v => ({
+ ...v,
+ additionalRecipients: []
+ }))
+ );
+ // 모든 첨부파일 선택
+ setSelectedAttachments(attachments.map(a => a.id));
+ }
+ }, [open, selectedVendors, attachments]);
+
+ // 추가 수신처 이메일 추가
+ const handleAddRecipient = (vendorId: number, email: string) => {
+ if (!email) return;
+
+ // 이메일 유효성 검사
+ const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
+ if (!emailRegex.test(email)) {
+ toast.error("올바른 이메일 형식이 아닙니다.");
+ return;
+ }
+
+ setVendorsWithRecipients(prev =>
+ prev.map(v =>
+ v.vendorId === vendorId
+ ? { ...v, additionalRecipients: [...v.additionalRecipients, email] }
+ : v
+ )
+ );
+ };
+
+ // 추가 수신처 이메일 제거
+ const handleRemoveRecipient = (vendorId: number, index: number) => {
+ setVendorsWithRecipients(prev =>
+ prev.map(v =>
+ v.vendorId === vendorId
+ ? {
+ ...v,
+ additionalRecipients: v.additionalRecipients.filter((_, i) => i !== index)
+ }
+ : v
+ )
+ );
+ };
+
+ // 첨부파일 선택 토글
+ const toggleAttachment = (attachmentId: number) => {
+ setSelectedAttachments(prev =>
+ prev.includes(attachmentId)
+ ? prev.filter(id => id !== attachmentId)
+ : [...prev, attachmentId]
+ );
+ };
+
+ // 전송 처리
+ const handleSend = async () => {
+ try {
+ setIsSending(true);
+
+ // 유효성 검사
+ if (selectedAttachments.length === 0) {
+ toast.warning("최소 하나 이상의 첨부파일을 선택해주세요.");
+ return;
+ }
+
+ await onSend({
+ vendors: vendorsWithRecipients,
+ attachments: selectedAttachments,
+ message: additionalMessage,
+ });
+
+ toast.success(`${vendorsWithRecipients.length}개 업체에 RFQ를 발송했습니다.`);
+ onOpenChange(false);
+ } catch (error) {
+ console.error("RFQ 발송 실패:", error);
+ toast.error("RFQ 발송에 실패했습니다.");
+ } finally {
+ setIsSending(false);
+ }
+ };
+
+ // 총 수신자 수 계산
+ const totalRecipientCount = React.useMemo(() => {
+ return vendorsWithRecipients.reduce((acc, v) =>
+ acc + 1 + v.additionalRecipients.length, 0
+ );
+ }, [vendorsWithRecipients]);
+
+ return (
+ <Dialog open={open} onOpenChange={onOpenChange}>
+ <DialogContent className="max-w-4xl max-h-[90vh] flex flex-col">
+ <DialogHeader>
+ <DialogTitle className="flex items-center gap-2">
+ <Send className="h-5 w-5" />
+ RFQ 일괄 발송
+ </DialogTitle>
+ <DialogDescription>
+ 선택한 {selectedVendors.length}개 업체에 RFQ를 발송합니다.
+ </DialogDescription>
+ </DialogHeader>
+
+ <ScrollArea className="flex-1 max-h-[calc(90vh-200px)]">
+ <div className="space-y-6 pr-4">
+ {/* RFQ 정보 섹션 */}
+ <div className="space-y-4">
+ <div className="flex items-center gap-2 text-sm font-medium">
+ <Info className="h-4 w-4" />
+ RFQ 정보
+ </div>
+
+ <div className="bg-muted/50 rounded-lg p-4 space-y-3">
+ {/* 프로젝트 정보 */}
+ <div className="grid grid-cols-2 gap-4 text-sm">
+ <div className="flex items-start gap-2">
+ <span className="text-muted-foreground min-w-[80px]">프로젝트:</span>
+ <span className="font-medium">
+ {rfqInfo.projectCode || "PN003"} ({rfqInfo.projectName || "PETRONAS ZLNG nearshore project"})
+ </span>
+ </div>
+ <div className="flex items-start gap-2">
+ <span className="text-muted-foreground min-w-[80px]">견적번호:</span>
+ <span className="font-medium font-mono">{rfqInfo.rfqCode}</span>
+ </div>
+ </div>
+
+ {/* 담당자 정보 */}
+ <div className="grid grid-cols-2 gap-4 text-sm">
+ <div className="flex items-start gap-2">
+ <span className="text-muted-foreground min-w-[80px]">구매담당:</span>
+ <span>
+ {rfqInfo.picName || "김*종"} ({rfqInfo.picCode || "86D"}) {rfqInfo.picTeam || "해양구매팀(해양구매1)"}
+ </span>
+ </div>
+ <div className="flex items-start gap-2">
+ <span className="text-muted-foreground min-w-[80px]">설계담당:</span>
+ <span>
+ {rfqInfo.designPicName || "이*진"} {rfqInfo.designTeam || "전장설계팀 (전장기기시스템)"}
+ </span>
+ </div>
+ </div>
+
+ {/* PKG 및 자재 정보 */}
+ <div className="grid grid-cols-2 gap-4 text-sm">
+ <div className="flex items-start gap-2">
+ <span className="text-muted-foreground min-w-[80px]">PKG 정보:</span>
+ <span>
+ {rfqInfo.packageNo || "MM03"} ({rfqInfo.packageName || "Deck Machinery"})
+ </span>
+ </div>
+ <div className="flex items-start gap-2">
+ <span className="text-muted-foreground min-w-[80px]">자재그룹:</span>
+ <span>
+ {rfqInfo.materialGroup || "BE2101"} ({rfqInfo.materialGroupDesc || "Combined Windlass & Mooring Wi"})
+ </span>
+ </div>
+ </div>
+
+ {/* 견적 정보 */}
+ <div className="grid grid-cols-2 gap-4 text-sm">
+ <div className="flex items-start gap-2">
+ <span className="text-muted-foreground min-w-[80px]">견적마감일:</span>
+ <span className="font-medium text-red-600">
+ {format(rfqInfo.dueDate, "yyyy.MM.dd", { locale: ko })}
+ </span>
+ </div>
+ <div className="flex items-start gap-2">
+ <span className="text-muted-foreground min-w-[80px]">평가적용:</span>
+ <Badge variant={rfqInfo.evaluationApply ? "default" : "outline"}>
+ {rfqInfo.evaluationApply ? "Y" : "N"}
+ </Badge>
+ </div>
+ </div>
+
+ {/* 견적명 */}
+ <div className="flex items-start gap-2 text-sm">
+ <span className="text-muted-foreground min-w-[80px]">견적명:</span>
+ <span className="font-medium">{rfqInfo.rfqTitle}</span>
+ </div>
+
+ {/* 계약구분 (일반견적일 때만) */}
+ {rfqInfo.rfqType === "일반견적" && (
+ <div className="flex items-start gap-2 text-sm">
+ <span className="text-muted-foreground min-w-[80px]">계약구분:</span>
+ <span>{rfqInfo.contractType || "-"}</span>
+ </div>
+ )}
+ </div>
+ </div>
+
+ <Separator />
+
+ {/* 첨부파일 섹션 */}
+ <div className="space-y-4">
+ <div className="flex items-center justify-between">
+ <div className="flex items-center gap-2 text-sm font-medium">
+ <Paperclip className="h-4 w-4" />
+ 첨부파일 ({selectedAttachments.length}/{attachments.length})
+ </div>
+ <Button
+ variant="ghost"
+ size="sm"
+ onClick={() => {
+ if (selectedAttachments.length === attachments.length) {
+ setSelectedAttachments([]);
+ } else {
+ setSelectedAttachments(attachments.map(a => a.id));
+ }
+ }}
+ >
+ {selectedAttachments.length === attachments.length ? "전체 해제" : "전체 선택"}
+ </Button>
+ </div>
+
+ <div className="border rounded-lg divide-y">
+ {attachments.length > 0 ? (
+ attachments.map((attachment) => (
+ <div
+ key={attachment.id}
+ className="flex items-center justify-between p-3 hover:bg-muted/50 transition-colors"
+ >
+ <div className="flex items-center gap-3">
+ <Checkbox
+ checked={selectedAttachments.includes(attachment.id)}
+ onCheckedChange={() => toggleAttachment(attachment.id)}
+ />
+ {getAttachmentIcon(attachment.attachmentType)}
+ <div>
+ <div className="flex items-center gap-2">
+ <span className="text-sm font-medium">
+ {attachment.fileName || `${attachment.attachmentType}_${attachment.serialNo}`}
+ </span>
+ <Badge variant="outline" className="text-xs">
+ {attachment.currentRevision}
+ </Badge>
+ </div>
+ {attachment.description && (
+ <p className="text-xs text-muted-foreground mt-0.5">
+ {attachment.description}
+ </p>
+ )}
+ </div>
+ </div>
+ <div className="flex items-center gap-2">
+ <span className="text-xs text-muted-foreground">
+ {formatFileSize(attachment.fileSize)}
+ </span>
+ </div>
+ </div>
+ ))
+ ) : (
+ <div className="p-8 text-center text-muted-foreground">
+ <Paperclip className="h-8 w-8 mx-auto mb-2 opacity-50" />
+ <p className="text-sm">첨부파일이 없습니다.</p>
+ </div>
+ )}
+ </div>
+ </div>
+
+ <Separator />
+
+ {/* 수신 업체 섹션 */}
+ <div className="space-y-4">
+ <div className="flex items-center justify-between">
+ <div className="flex items-center gap-2 text-sm font-medium">
+ <Building2 className="h-4 w-4" />
+ 수신 업체 ({selectedVendors.length})
+ </div>
+ <Badge variant="outline" className="flex items-center gap-1">
+ <Users className="h-3 w-3" />
+ 총 {totalRecipientCount}명
+ </Badge>
+ </div>
+
+ <div className="space-y-3">
+ {vendorsWithRecipients.map((vendor, index) => (
+ <div
+ key={vendor.vendorId}
+ className="border rounded-lg p-4 space-y-3"
+ >
+ {/* 업체 정보 */}
+ <div className="flex items-start justify-between">
+ <div className="flex items-center gap-3">
+ <div className="flex items-center justify-center w-8 h-8 rounded-full bg-primary/10 text-primary text-sm font-medium">
+ {index + 1}
+ </div>
+ <div>
+ <div className="flex items-center gap-2">
+ <span className="font-medium">{vendor.vendorName}</span>
+ <Badge variant="outline" className="text-xs">
+ {vendor.vendorCountry}
+ </Badge>
+ </div>
+ {vendor.vendorCode && (
+ <span className="text-xs text-muted-foreground">
+ {vendor.vendorCode}
+ </span>
+ )}
+ </div>
+ </div>
+ <Badge variant="secondary">
+ 주 수신: {vendor.vendorEmail || "vendor@example.com"}
+ </Badge>
+ </div>
+
+ {/* 추가 수신처 */}
+ <div className="pl-11 space-y-2">
+ <div className="flex items-center gap-2">
+ <Label className="text-xs text-muted-foreground">추가 수신처:</Label>
+ <TooltipProvider>
+ <Tooltip>
+ <TooltipTrigger>
+ <AlertCircle className="h-3 w-3 text-muted-foreground" />
+ </TooltipTrigger>
+ <TooltipContent>
+ <p>참조로 RFQ를 받을 추가 이메일 주소를 입력하세요.</p>
+ </TooltipContent>
+ </Tooltip>
+ </TooltipProvider>
+ </div>
+
+ {/* 추가된 이메일 목록 */}
+ <div className="flex flex-wrap gap-2">
+ {vendor.additionalRecipients.map((email, idx) => (
+ <Badge
+ key={idx}
+ variant="outline"
+ className="flex items-center gap-1 pr-1"
+ >
+ <Mail className="h-3 w-3" />
+ {email}
+ <Button
+ variant="ghost"
+ size="sm"
+ className="h-4 w-4 p-0 hover:bg-transparent"
+ onClick={() => handleRemoveRecipient(vendor.vendorId, idx)}
+ >
+ <X className="h-3 w-3" />
+ </Button>
+ </Badge>
+ ))}
+ </div>
+
+ {/* 이메일 입력 필드 */}
+ <div className="flex gap-2">
+ <Input
+ type="email"
+ placeholder="추가 수신자 이메일 입력"
+ className="h-8 text-sm"
+ onKeyDown={(e) => {
+ if (e.key === "Enter") {
+ e.preventDefault();
+ const input = e.target as HTMLInputElement;
+ handleAddRecipient(vendor.vendorId, input.value);
+ input.value = "";
+ }
+ }}
+ />
+ <Button
+ variant="outline"
+ size="sm"
+ onClick={(e) => {
+ const input = (e.currentTarget.previousElementSibling as HTMLInputElement);
+ handleAddRecipient(vendor.vendorId, input.value);
+ input.value = "";
+ }}
+ >
+ <Plus className="h-4 w-4" />
+ </Button>
+ </div>
+ </div>
+ </div>
+ ))}
+ </div>
+ </div>
+
+ <Separator />
+
+ {/* 추가 메시지 (선택사항) */}
+ <div className="space-y-2">
+ <Label htmlFor="message" className="text-sm font-medium">
+ 추가 메시지 (선택사항)
+ </Label>
+ <textarea
+ id="message"
+ className="w-full min-h-[80px] p-3 text-sm border rounded-lg resize-none focus:outline-none focus:ring-2 focus:ring-primary"
+ placeholder="업체에 전달할 추가 메시지를 입력하세요..."
+ value={additionalMessage}
+ onChange={(e) => setAdditionalMessage(e.target.value)}
+ />
+ </div>
+ </div>
+ </ScrollArea>
+
+ <DialogFooter className="flex-shrink-0">
+ <Alert className="mr-auto max-w-md">
+ <AlertCircle className="h-4 w-4" />
+ <AlertDescription className="text-xs">
+ 발송 후에는 취소할 수 없습니다. 발송 내용을 다시 한번 확인해주세요.
+ </AlertDescription>
+ </Alert>
+ <Button
+ variant="outline"
+ onClick={() => onOpenChange(false)}
+ disabled={isSending}
+ >
+ 취소
+ </Button>
+ <Button
+ onClick={handleSend}
+ disabled={isSending || selectedAttachments.length === 0}
+ >
+ {isSending ? (
+ <>
+ <RefreshCw className="h-4 w-4 mr-2 animate-spin" />
+ 발송중...
+ </>
+ ) : (
+ <>
+ <Send className="h-4 w-4 mr-2" />
+ RFQ 발송
+ </>
+ )}
+ </Button>
+ </DialogFooter>
+ </DialogContent>
+ </Dialog>
+ );
+} \ No newline at end of file
diff --git a/lib/vendor-regular-registrations/table/vendor-regular-registrations-table-columns.tsx b/lib/vendor-regular-registrations/table/vendor-regular-registrations-table-columns.tsx
index 8d21df24..6a067d68 100644
--- a/lib/vendor-regular-registrations/table/vendor-regular-registrations-table-columns.tsx
+++ b/lib/vendor-regular-registrations/table/vendor-regular-registrations-table-columns.tsx
@@ -172,7 +172,7 @@ export function getColumns(): ColumnDef<VendorRegularRegistration>[] {
// 기본계약 현황 계산
const totalContracts = registration.basicContracts?.length || 0
- const completedContracts = registration.basicContracts?.filter(c => c.status === "COMPLETED").length || 0
+ const completedContracts = registration.basicContracts?.filter(c => c.status === "VENDOR_SIGNED").length || 0
const incompleteContracts = totalContracts - completedContracts
// 안전적격성 평가 현황
@@ -184,7 +184,7 @@ export function getColumns(): ColumnDef<VendorRegularRegistration>[] {
// 전체 미완료 항목 계산
const totalIncomplete =
(incompleteDocs > 0 ? 1 : 0) +
- (incompleteContracts > 0 ? 1 : 0) +
+ incompleteContracts +
(!safetyCompleted ? 1 : 0) +
(!additionalInfoCompleted ? 1 : 0)