summaryrefslogtreecommitdiff
path: root/lib/bidding/detail
diff options
context:
space:
mode:
Diffstat (limited to 'lib/bidding/detail')
-rw-r--r--lib/bidding/detail/service.ts508
-rw-r--r--lib/bidding/detail/table/bidding-detail-vendor-columns.tsx78
-rw-r--r--lib/bidding/detail/table/bidding-detail-vendor-table.tsx46
-rw-r--r--lib/bidding/detail/table/bidding-detail-vendor-toolbar-actions.tsx228
-rw-r--r--lib/bidding/detail/table/price-adjustment-dialog.tsx195
-rw-r--r--lib/bidding/detail/table/vendor-price-adjustment-view-dialog.tsx324
6 files changed, 969 insertions, 410 deletions
diff --git a/lib/bidding/detail/service.ts b/lib/bidding/detail/service.ts
index f52ecb1e..eec3f253 100644
--- a/lib/bidding/detail/service.ts
+++ b/lib/bidding/detail/service.ts
@@ -3,7 +3,7 @@
import db from '@/db/db'
import { biddings, prItemsForBidding, biddingDocuments, biddingCompanies, vendors, companyPrItemBids, companyConditionResponses, vendorSelectionResults, priceAdjustmentForms, users, vendorContacts } from '@/db/schema'
import { specificationMeetings, biddingCompaniesContacts } from '@/db/schema/bidding'
-import { eq, and, sql, desc, ne, asc } from 'drizzle-orm'
+import { eq, and, sql, desc, ne, asc, inArray } from 'drizzle-orm'
import { revalidatePath, revalidateTag } from 'next/cache'
import { unstable_cache } from "@/lib/unstable-cache";
import { sendEmail } from '@/lib/mail/sendEmail'
@@ -30,43 +30,113 @@ async function getUserNameById(userId: string): Promise<string> {
// 데이터 조회 함수들
export interface BiddingDetailData {
bidding: Awaited<ReturnType<typeof getBiddingById>>
- quotationDetails: QuotationDetails | null
+ quotationDetails: null
quotationVendors: QuotationVendor[]
- prItems: Awaited<ReturnType<typeof getPRItemsForBidding>>
+ prItems: Awaited<ReturnType<typeof getPrItemsForBidding>>
}
// getBiddingById 함수 임포트 (기존 함수 재사용)
import { getBiddingById, updateBiddingProjectInfo } from '@/lib/bidding/service'
+import { getPrItemsForBidding } from '../pre-quote/service'
-// Promise.all을 사용하여 모든 데이터를 병렬로 조회 (캐시 적용)
+// Bidding Detail Data 조회 (캐시 제거, 로직 단순화)
export async function getBiddingDetailData(biddingId: number): Promise<BiddingDetailData> {
- return unstable_cache(
- async () => {
- const [
- bidding,
- quotationDetails,
- quotationVendors,
- prItems
- ] = await Promise.all([
- getBiddingById(biddingId),
- getQuotationDetails(biddingId),
- getQuotationVendors(biddingId),
- getPRItemsForBidding(biddingId)
- ])
+ try {
+ // 1. 입찰 정보 조회
+ const bidding = await getBiddingById(biddingId)
- return {
- bidding,
- quotationDetails,
- quotationVendors,
- prItems
+ // 2. 입찰 품목 조회 (pre-quote service 함수 재사용)
+ const prItems = await getPrItemsForBidding(biddingId)
+
+ // 3. 본입찰 제출 업체 조회 (bidding_submitted 상태)
+ const vendorsData = await db
+ .select({
+ id: biddingCompanies.id,
+ biddingId: biddingCompanies.biddingId,
+ vendorId: biddingCompanies.companyId,
+ vendorName: vendors.vendorName,
+ vendorCode: vendors.vendorCode,
+ vendorEmail: vendors.email,
+ quotationAmount: biddingCompanies.finalQuoteAmount,
+ currency: sql<string>`'KRW'`,
+ submissionDate: biddingCompanies.finalQuoteSubmittedAt,
+ isWinner: biddingCompanies.isWinner,
+ awardRatio: biddingCompanies.awardRatio,
+ isBiddingParticipated: biddingCompanies.isBiddingParticipated,
+ invitationStatus: biddingCompanies.invitationStatus,
+ // 연동제 관련 필드
+ isPriceAdjustmentApplicableQuestion: biddingCompanies.isPriceAdjustmentApplicableQuestion,
+ priceAdjustmentResponse: companyConditionResponses.priceAdjustmentResponse, // 벤더가 응답한 연동제 적용 여부
+ shiPriceAdjustmentApplied: biddingCompanies.shiPriceAdjustmentApplied,
+ priceAdjustmentNote: biddingCompanies.priceAdjustmentNote,
+ hasChemicalSubstance: biddingCompanies.hasChemicalSubstance,
+ // Contact info from biddingCompaniesContacts
+ contactPerson: biddingCompaniesContacts.contactName,
+ contactEmail: biddingCompaniesContacts.contactEmail,
+ contactPhone: biddingCompaniesContacts.contactNumber,
+ })
+ .from(biddingCompanies)
+ .innerJoin(vendors, eq(biddingCompanies.companyId, vendors.id))
+ .leftJoin(biddingCompaniesContacts, and(
+ eq(biddingCompaniesContacts.biddingId, biddingId),
+ eq(biddingCompaniesContacts.vendorId, biddingCompanies.companyId)
+ ))
+ .leftJoin(companyConditionResponses, and(
+ eq(companyConditionResponses.biddingCompanyId, biddingCompanies.id),
+ eq(companyConditionResponses.isPreQuote, false) // 본입찰 데이터만
+ ))
+ .where(and(
+ eq(biddingCompanies.biddingId, biddingId),
+ eq(biddingCompanies.isBiddingParticipated, true)
+ ))
+ .orderBy(desc(biddingCompanies.finalQuoteAmount))
+
+ // 중복 제거 (업체당 여러 담당자가 있을 경우 첫 번째만 사용하거나 처리)
+ // 여기서는 간단히 메모리에서 중복 제거 (biddingCompanyId 기준)
+ const uniqueVendors = vendorsData.reduce((acc, curr) => {
+ if (!acc.find(v => v.id === curr.id)) {
+ acc.push({
+ id: curr.id,
+ biddingId: curr.biddingId,
+ vendorId: curr.vendorId,
+ vendorName: curr.vendorName || `Vendor ${curr.vendorId}`,
+ vendorCode: curr.vendorCode || '',
+ vendorEmail: curr.vendorEmail || '',
+ contactPerson: curr.contactPerson || '',
+ contactEmail: curr.contactEmail || '',
+ contactPhone: curr.contactPhone || '',
+ quotationAmount: Number(curr.quotationAmount) || 0,
+ currency: curr.currency,
+ submissionDate: curr.submissionDate ? (curr.submissionDate instanceof Date ? curr.submissionDate.toISOString().split('T')[0] : String(curr.submissionDate).split('T')[0]) : '',
+ isWinner: curr.isWinner,
+ awardRatio: curr.awardRatio ? Number(curr.awardRatio) : null,
+ isBiddingParticipated: curr.isBiddingParticipated,
+ invitationStatus: curr.invitationStatus,
+ // 연동제 관련 필드
+ isPriceAdjustmentApplicableQuestion: curr.isPriceAdjustmentApplicableQuestion,
+ priceAdjustmentResponse: curr.priceAdjustmentResponse, // 벤더가 응답한 연동제 적용 여부
+ shiPriceAdjustmentApplied: curr.shiPriceAdjustmentApplied,
+ priceAdjustmentNote: curr.priceAdjustmentNote,
+ hasChemicalSubstance: curr.hasChemicalSubstance,
+ documents: [],
+ })
}
- },
- [`bidding-detail-data-${biddingId}`],
- {
- tags: [`bidding-${biddingId}`, 'bidding-detail', 'quotation-vendors', 'pr-items']
+ return acc
+ }, [] as QuotationVendor[])
+
+ return {
+ bidding,
+ quotationDetails: null,
+ quotationVendors: uniqueVendors,
+ prItems
}
- )()
+ } catch (error) {
+ console.error('Failed to get bidding detail data:', error)
+ throw error
+ }
}
+
+// QuotationDetails Interface (Keeping it for type safety if needed elsewhere, or remove if safe)
export interface QuotationDetails {
biddingId: number
estimatedPrice: number // 예상액
@@ -94,6 +164,12 @@ export interface QuotationVendor {
awardRatio: number | null // 발주비율
isBiddingParticipated: boolean | null // 본입찰 참여여부
invitationStatus: 'pending' | 'pre_quote_sent' | 'pre_quote_accepted' | 'pre_quote_declined' | 'pre_quote_submitted' | 'bidding_sent' | 'bidding_accepted' | 'bidding_declined' | 'bidding_cancelled' | 'bidding_submitted'
+ // 연동제 관련 필드
+ isPriceAdjustmentApplicableQuestion: boolean | null // SHI가 요청한 연동제 요청 여부
+ priceAdjustmentResponse: boolean | null // 벤더가 응답한 연동제 적용 여부 (companyConditionResponses.priceAdjustmentResponse)
+ shiPriceAdjustmentApplied: boolean | null // SHI 연동제 적용여부
+ priceAdjustmentNote: string | null // 연동제 Note
+ hasChemicalSubstance: boolean | null // 화학물질여부
documents: Array<{
id: number
fileName: string
@@ -103,66 +179,6 @@ export interface QuotationVendor {
}>
}
-// 견적 시스템에서 내정가 및 관련 정보를 가져오는 함수 (캐시 적용)
-export async function getQuotationDetails(biddingId: number): Promise<QuotationDetails | null> {
- return unstable_cache(
- async () => {
- try {
- // bidding_companies 테이블에서 견적 데이터를 집계
- const quotationStats = await db
- .select({
- biddingId: biddingCompanies.biddingId,
- estimatedPrice: sql<number>`AVG(${biddingCompanies.finalQuoteAmount})`.as('estimated_price'),
- lowestQuote: sql<number>`MIN(${biddingCompanies.finalQuoteAmount})`.as('lowest_quote'),
- averageQuote: sql<number>`AVG(${biddingCompanies.finalQuoteAmount})`.as('average_quote'),
- targetPrice: sql<number>`AVG(${biddings.targetPrice})`.as('target_price'),
- quotationCount: sql<number>`COUNT(*)`.as('quotation_count'),
- lastUpdated: sql<string>`MAX(${biddingCompanies.updatedAt})`.as('last_updated')
- })
- .from(biddingCompanies)
- .leftJoin(biddings, eq(biddingCompanies.biddingId, biddings.id))
- .where(and(
- eq(biddingCompanies.biddingId, biddingId),
- sql`${biddingCompanies.finalQuoteAmount} IS NOT NULL`
- ))
- .groupBy(biddingCompanies.biddingId)
- .limit(1)
-
- if (quotationStats.length === 0) {
- return {
- biddingId,
- estimatedPrice: 0,
- lowestQuote: 0,
- averageQuote: 0,
- targetPrice: 0,
- quotationCount: 0,
- lastUpdated: new Date().toISOString()
- }
- }
-
- const stat = quotationStats[0]
-
- return {
- biddingId,
- estimatedPrice: Number(stat.estimatedPrice) || 0,
- lowestQuote: Number(stat.lowestQuote) || 0,
- averageQuote: Number(stat.averageQuote) || 0,
- targetPrice: Number(stat.targetPrice) || 0,
- quotationCount: Number(stat.quotationCount) || 0,
- lastUpdated: stat.lastUpdated || new Date().toISOString()
- }
- } catch (error) {
- console.error('Failed to get quotation details:', error)
- return null
- }
- },
- [`quotation-details-${biddingId}`],
- {
- tags: [`bidding-${biddingId}`, 'quotation-details']
- }
- )()
-}
-
// bidding_companies 테이블을 메인으로 vendors 테이블을 조인하여 협력업체 정보 조회
export async function getBiddingCompaniesData(biddingId: number) {
try {
@@ -281,7 +297,7 @@ export async function getAllBiddingCompanies(biddingId: number) {
}
}
-// prItemsForBidding 테이블에서 품목 정보 조회 (캐시 미적용, always fresh)
+// prItemsForBidding 테이블에서 품목 정보 조회 (deprecated - import from pre-quote/service)
export async function getPRItemsForBidding(biddingId: number) {
try {
const items = await db
@@ -297,70 +313,9 @@ export async function getPRItemsForBidding(biddingId: number) {
}
}
-// 견적 시스템에서 협력업체 정보를 가져오는 함수 (캐시 적용)
-export async function getQuotationVendors(biddingId: number): Promise<QuotationVendor[]> {
- return unstable_cache(
- async () => {
- try {
- // bidding_companies 테이블을 메인으로 vendors를 조인하여 협력업체 정보 조회
- const vendorsData = await db
- .select({
- id: biddingCompanies.id,
- biddingId: biddingCompanies.biddingId,
- vendorId: biddingCompanies.companyId,
- vendorName: vendors.vendorName,
- vendorCode: vendors.vendorCode,
- vendorEmail: vendors.email, // 벤더의 기본 이메일
- contactPerson: biddingCompanies.contactPerson,
- contactEmail: biddingCompanies.contactEmail,
- contactPhone: biddingCompanies.contactPhone,
- quotationAmount: biddingCompanies.finalQuoteAmount,
- currency: sql<string>`'KRW'`,
- submissionDate: biddingCompanies.finalQuoteSubmittedAt,
- isWinner: biddingCompanies.isWinner,
- // awardRatio: sql<number>`CASE WHEN ${biddingCompanies.isWinner} THEN 100 ELSE 0 END`,
- awardRatio: biddingCompanies.awardRatio,
- isBiddingParticipated: biddingCompanies.isBiddingParticipated,
- invitationStatus: biddingCompanies.invitationStatus,
- })
- .from(biddingCompanies)
- .leftJoin(vendors, eq(biddingCompanies.companyId, vendors.id))
- .where(and(
- eq(biddingCompanies.biddingId, biddingId),
- eq(biddingCompanies.isPreQuoteSelected, true) // 본입찰 선정된 업체만 조회
- ))
- .orderBy(desc(biddingCompanies.finalQuoteAmount))
+// 견적 시스템에서 협력업체 정보를 가져오는 함수 (Deprecated - integrated into getBiddingDetailData)
+// export async function getQuotationVendors(biddingId: number): Promise<QuotationVendor[]> { ... }
- return vendorsData.map(vendor => ({
- id: vendor.id,
- biddingId: vendor.biddingId,
- vendorId: vendor.vendorId,
- vendorName: vendor.vendorName || `Vendor ${vendor.vendorId}`,
- vendorCode: vendor.vendorCode || '',
- vendorEmail: vendor.vendorEmail || '', // 벤더의 기본 이메일
- contactPerson: vendor.contactPerson || '',
- contactEmail: vendor.contactEmail || '',
- contactPhone: vendor.contactPhone || '',
- quotationAmount: Number(vendor.quotationAmount) || 0,
- currency: vendor.currency,
- submissionDate: vendor.submissionDate ? (vendor.submissionDate instanceof Date ? vendor.submissionDate.toISOString().split('T')[0] : String(vendor.submissionDate).split('T')[0]) : '',
- isWinner: vendor.isWinner,
- awardRatio: vendor.awardRatio ? Number(vendor.awardRatio) : null,
- isBiddingParticipated: vendor.isBiddingParticipated,
- invitationStatus: vendor.invitationStatus,
- documents: [], // 빈 배열로 초기화
- }))
- } 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) {
@@ -898,11 +853,59 @@ export async function registerBidding(biddingId: number, userId: string) {
await db.transaction(async (tx) => {
debugLog('registerBidding: Transaction started')
- // 1. 입찰 상태를 오픈으로 변경
+
+ // 0. 입찰서 제출기간 계산 (입력값 절대 기준)
+ const { submissionStartOffset, submissionDurationDays, submissionStartDate, submissionEndDate } = bidding
+
+ let calculatedStartDate = bidding.submissionStartDate
+ let calculatedEndDate = bidding.submissionEndDate
+
+ if (submissionStartOffset !== null && submissionDurationDays !== null) {
+ // DB에 저장된 시간을 숫자 그대로 가져옴 (예: 10:00 저장 → 10 반환)
+ const startTime = submissionStartDate
+ ? { hours: submissionStartDate.getUTCHours(), minutes: submissionStartDate.getUTCMinutes() }
+ : { hours: 9, minutes: 0 }
+ const endTime = submissionEndDate
+ ? { hours: submissionEndDate.getUTCHours(), minutes: submissionEndDate.getUTCMinutes() }
+ : { hours: 18, minutes: 0 }
+
+ // 서버의 오늘 날짜(년/월/일)를 그대로 사용해 00:00 UTC 시점 생성
+ const now = new Date()
+ const baseDate = new Date(Date.UTC(
+ now.getFullYear(),
+ now.getMonth(),
+ now.getDate(),
+ 0, 0, 0
+ ))
+
+ // 시작일 = baseDate + offset일 + 입력 시간(숫자 그대로)
+ const tempStartDate = new Date(baseDate)
+ tempStartDate.setUTCDate(tempStartDate.getUTCDate() + submissionStartOffset)
+ tempStartDate.setUTCHours(startTime.hours, startTime.minutes, 0, 0)
+
+ // 마감일 = 시작일 날짜만 기준 + duration일 + 입력 마감 시간
+ const tempEndDate = new Date(tempStartDate)
+ tempEndDate.setUTCHours(0, 0, 0, 0)
+ tempEndDate.setUTCDate(tempEndDate.getUTCDate() + submissionDurationDays)
+ tempEndDate.setUTCHours(endTime.hours, endTime.minutes, 0, 0)
+
+ calculatedStartDate = tempStartDate
+ calculatedEndDate = tempEndDate
+
+ debugLog('registerBidding: Submission dates calculated (Input Value Based)', {
+ baseDate: baseDate.toISOString(),
+ calculatedStartDate: calculatedStartDate.toISOString(),
+ calculatedEndDate: calculatedEndDate.toISOString(),
+ })
+ }
+
+ // 1. 입찰 상태를 오픈으로 변경 + 제출기간 업데이트
await tx
.update(biddings)
.set({
status: 'bidding_opened',
+ submissionStartDate: calculatedStartDate,
+ submissionEndDate: calculatedEndDate,
updatedBy: userName,
updatedAt: new Date()
})
@@ -1368,10 +1371,14 @@ export async function getAwardedCompanies(biddingId: number) {
companyId: biddingCompanies.companyId,
companyName: vendors.vendorName,
finalQuoteAmount: biddingCompanies.finalQuoteAmount,
- awardRatio: biddingCompanies.awardRatio
+ awardRatio: biddingCompanies.awardRatio,
+ vendorCode: vendors.vendorCode,
+ companySize: vendors.businessSize,
+ targetPrice: biddings.targetPrice
})
.from(biddingCompanies)
.leftJoin(vendors, eq(biddingCompanies.companyId, vendors.id))
+ .leftJoin(biddings, eq(biddingCompanies.biddingId, biddings.id))
.where(and(
eq(biddingCompanies.biddingId, biddingId),
eq(biddingCompanies.isWinner, true)
@@ -1381,7 +1388,10 @@ export async function getAwardedCompanies(biddingId: number) {
companyId: company.companyId,
companyName: company.companyName,
finalQuoteAmount: parseFloat(company.finalQuoteAmount?.toString() || '0'),
- awardRatio: parseFloat(company.awardRatio?.toString() || '0')
+ awardRatio: parseFloat(company.awardRatio?.toString() || '0'),
+ vendorCode: company.vendorCode,
+ companySize: company.companySize,
+ targetPrice: company.targetPrice ? parseFloat(company.targetPrice.toString()) : 0
}))
} catch (error) {
console.error('Failed to get awarded companies:', error)
@@ -1410,7 +1420,7 @@ async function updateBiddingAmounts(biddingId: number) {
.set({
targetPrice: totalTargetAmount.toString(),
budget: totalBudgetAmount.toString(),
- finalBidPrice: totalActualAmount.toString(),
+ actualPrice: totalActualAmount.toString(),
updatedAt: new Date()
})
.where(eq(biddings.id, biddingId))
@@ -1693,7 +1703,7 @@ export interface PartnersBiddingListItem {
biddingNumber: string
originalBiddingNumber: string | null // 원입찰번호
revision: number | null
- projectName: string
+ projectName: string | null
itemName: string
title: string
contractType: string
@@ -1782,9 +1792,9 @@ export async function getBiddingListForPartners(companyId: number): Promise<Part
// 계산된 필드 추가
const resultWithCalculatedFields = result.map(item => ({
...item,
- respondedAt: item.respondedAt ? (item.respondedAt instanceof Date ? item.respondedAt.toISOString() : item.respondedAt.toString()) : null,
+ respondedAt: item.respondedAt ? (item.respondedAt instanceof Date ? item.respondedAt.toISOString() : String(item.respondedAt)) : null,
finalQuoteAmount: item.finalQuoteAmount ? Number(item.finalQuoteAmount) : null, // string을 number로 변환
- finalQuoteSubmittedAt: item.finalQuoteSubmittedAt ? (item.finalQuoteSubmittedAt instanceof Date ? item.finalQuoteSubmittedAt.toISOString() : item.finalQuoteSubmittedAt.toString()) : null,
+ finalQuoteSubmittedAt: item.finalQuoteSubmittedAt ? (item.finalQuoteSubmittedAt instanceof Date ? item.finalQuoteSubmittedAt.toISOString() : String(item.finalQuoteSubmittedAt)) : null,
responseDeadline: item.submissionStartDate
? new Date(item.submissionStartDate.getTime() - 3 * 24 * 60 * 60 * 1000) // 3일 전
: null,
@@ -1825,7 +1835,6 @@ export async function getBiddingDetailsForPartners(biddingId: number, companyId:
biddingRegistrationDate: biddings.biddingRegistrationDate,
submissionStartDate: biddings.submissionStartDate,
submissionEndDate: biddings.submissionEndDate,
- evaluationDate: biddings.evaluationDate,
// 가격 정보
currency: biddings.currency,
@@ -2596,101 +2605,72 @@ export async function getBiddingDocumentsForPartners(biddingId: number) {
// 입찰가 비교 분석 함수들
// =================================================
-// 벤더별 입찰가 정보 조회 (캐시 적용)
+// 벤더별 입찰가 정보 조회 (최적화 및 간소화됨)
export async function getVendorPricesForBidding(biddingId: number) {
- return unstable_cache(
- async () => {
- try {
- // 각 회사의 입찰가 정보를 조회 - 본입찰 참여 업체들
- const vendorPrices = await db
- .select({
- companyId: biddingCompanies.companyId,
- companyName: vendors.vendorName,
- biddingCompanyId: biddingCompanies.id,
- currency: sql<string>`'KRW'`, // 기본값 KRW
- finalQuoteAmount: biddingCompanies.finalQuoteAmount,
- isBiddingParticipated: biddingCompanies.isBiddingParticipated,
- })
- .from(biddingCompanies)
- .leftJoin(vendors, eq(biddingCompanies.companyId, vendors.id))
- .where(and(
- eq(biddingCompanies.biddingId, biddingId),
- eq(biddingCompanies.isBiddingParticipated, true), // 본입찰 참여 업체만
- sql`${biddingCompanies.finalQuoteAmount} IS NOT NULL` // 입찰가를 제출한 업체만
- ))
+ try {
+ // 1. 본입찰 참여 업체들 조회
+ const participatingVendors = await db
+ .select({
+ companyId: biddingCompanies.companyId,
+ companyName: vendors.vendorName,
+ biddingCompanyId: biddingCompanies.id,
+ currency: sql<string>`'KRW'`, // 기본값 KRW
+ finalQuoteAmount: biddingCompanies.finalQuoteAmount,
+ isBiddingParticipated: biddingCompanies.isBiddingParticipated,
+ })
+ .from(biddingCompanies)
+ .leftJoin(vendors, eq(biddingCompanies.companyId, vendors.id))
+ .where(and(
+ eq(biddingCompanies.biddingId, biddingId),
+ eq(biddingCompanies.isBiddingParticipated, true) // 본입찰 참여 업체만
+ ))
- console.log(`Found ${vendorPrices.length} vendors for bidding ${biddingId}`)
+ if (participatingVendors.length === 0) {
+ return []
+ }
- const result: any[] = []
+ const biddingCompanyIds = participatingVendors.map(v => v.biddingCompanyId)
- for (const vendor of vendorPrices) {
- try {
- // 해당 회사의 품목별 입찰가 조회 (본입찰 데이터)
- const itemPrices = await db
- .select({
- prItemId: companyPrItemBids.prItemId,
- itemName: prItemsForBidding.itemInfo, // itemInfo 사용
- itemNumber: prItemsForBidding.itemNumber, // itemNumber도 포함
- quantity: prItemsForBidding.quantity,
- quantityUnit: prItemsForBidding.quantityUnit,
- weight: prItemsForBidding.totalWeight, // totalWeight 사용
- weightUnit: prItemsForBidding.weightUnit,
- unitPrice: companyPrItemBids.bidUnitPrice,
- amount: companyPrItemBids.bidAmount,
- proposedDeliveryDate: companyPrItemBids.proposedDeliveryDate,
- })
- .from(companyPrItemBids)
- .leftJoin(prItemsForBidding, eq(companyPrItemBids.prItemId, prItemsForBidding.id))
- .where(and(
- eq(companyPrItemBids.biddingCompanyId, vendor.biddingCompanyId),
- eq(companyPrItemBids.isPreQuote, false) // 본입찰 데이터만
- ))
- .orderBy(prItemsForBidding.id)
-
- console.log(`Vendor ${vendor.companyName}: Found ${itemPrices.length} item prices`)
-
- // 총 금액은 biddingCompanies.finalQuoteAmount 사용
- const totalAmount = parseFloat(vendor.finalQuoteAmount || '0')
-
- result.push({
- companyId: vendor.companyId,
- companyName: vendor.companyName || `Vendor ${vendor.companyId}`,
- biddingCompanyId: vendor.biddingCompanyId,
- totalAmount,
- currency: vendor.currency,
- itemPrices: itemPrices.map(item => ({
- prItemId: item.prItemId,
- itemName: item.itemName || item.itemNumber || `Item ${item.prItemId}`,
- quantity: parseFloat(item.quantity || '0'),
- quantityUnit: item.quantityUnit || 'ea',
- weight: item.weight ? parseFloat(item.weight) : null,
- weightUnit: item.weightUnit,
- unitPrice: parseFloat(item.unitPrice || '0'),
- amount: parseFloat(item.amount || '0'),
- proposedDeliveryDate: item.proposedDeliveryDate ?
- (typeof item.proposedDeliveryDate === 'string'
- ? item.proposedDeliveryDate
- : item.proposedDeliveryDate.toISOString().split('T')[0])
- : null,
- }))
- })
- } catch (vendorError) {
- console.error(`Error processing vendor ${vendor.companyId}:`, vendorError)
- // 벤더 처리 중 에러가 발생해도 다른 벤더들은 계속 처리
- }
- }
+ // 2. 해당 업체들의 입찰 품목 조회 (한 번의 쿼리로 최적화)
+ // 필요한 필드만 조회: prItemId, bidUnitPrice, bidAmount
+ const allItemBids = await db
+ .select({
+ biddingCompanyId: companyPrItemBids.biddingCompanyId,
+ prItemId: companyPrItemBids.prItemId,
+ bidUnitPrice: companyPrItemBids.bidUnitPrice,
+ bidAmount: companyPrItemBids.bidAmount,
+ })
+ .from(companyPrItemBids)
+ .where(and(
+ inArray(companyPrItemBids.biddingCompanyId, biddingCompanyIds),
+ eq(companyPrItemBids.isPreQuote, false) // 본입찰 데이터만
+ ))
- return result
- } catch (error) {
- console.error('Failed to get vendor prices for bidding:', error)
- return []
+ // 3. 업체별로 데이터 매핑
+ const result = participatingVendors.map(vendor => {
+ const vendorItems = allItemBids.filter(item => item.biddingCompanyId === vendor.biddingCompanyId)
+
+ const totalAmount = parseFloat(vendor.finalQuoteAmount || '0')
+
+ return {
+ companyId: vendor.companyId,
+ companyName: vendor.companyName || `Vendor ${vendor.companyId}`,
+ biddingCompanyId: vendor.biddingCompanyId,
+ totalAmount,
+ currency: vendor.currency,
+ itemPrices: vendorItems.map(item => ({
+ prItemId: item.prItemId,
+ unitPrice: parseFloat(item.bidUnitPrice || '0'),
+ amount: parseFloat(item.bidAmount || '0'),
+ }))
}
- },
- [`bidding-vendor-prices-${biddingId}`],
- {
- tags: [`bidding-${biddingId}`, 'quotation-vendors', 'pr-items']
- }
- )()
+ })
+
+ return result
+ } catch (error) {
+ console.error('Failed to get vendor prices for bidding:', error)
+ return []
+ }
}
// 사양설명회 참여 여부 업데이트
@@ -2720,3 +2700,35 @@ export async function setSpecificationMeetingParticipation(biddingCompanyId: num
return { success: false, error: '사양설명회 참여상태 업데이트에 실패했습니다.' }
}
}
+
+// 연동제 정보 업데이트
+export async function updatePriceAdjustmentInfo(params: {
+ biddingCompanyId: number
+ shiPriceAdjustmentApplied: boolean | null
+ priceAdjustmentNote: string | null
+ hasChemicalSubstance: boolean | null
+}): Promise<{ success: boolean; error?: string }> {
+ try {
+ const result = await db.update(biddingCompanies)
+ .set({
+ shiPriceAdjustmentApplied: params.shiPriceAdjustmentApplied,
+ priceAdjustmentNote: params.priceAdjustmentNote,
+ hasChemicalSubstance: params.hasChemicalSubstance,
+ updatedAt: new Date(),
+ })
+ .where(eq(biddingCompanies.id, params.biddingCompanyId))
+ .returning({ biddingId: biddingCompanies.biddingId })
+
+ if (result.length > 0) {
+ const biddingId = result[0].biddingId
+ revalidateTag(`bidding-${biddingId}`)
+ revalidateTag('quotation-vendors')
+ revalidatePath(`/evcp/bid/${biddingId}`)
+ }
+
+ return { success: true }
+ } catch (error) {
+ console.error('Failed to update price adjustment info:', error)
+ return { success: false, error: '연동제 정보 업데이트에 실패했습니다.' }
+ }
+}
diff --git a/lib/bidding/detail/table/bidding-detail-vendor-columns.tsx b/lib/bidding/detail/table/bidding-detail-vendor-columns.tsx
index 5368b287..05c1a93d 100644
--- a/lib/bidding/detail/table/bidding-detail-vendor-columns.tsx
+++ b/lib/bidding/detail/table/bidding-detail-vendor-columns.tsx
@@ -31,6 +31,7 @@ interface GetVendorColumnsProps {
}
export function getBiddingDetailVendorColumns({
+ onViewPriceAdjustment,
onViewItemDetails,
onSendBidding,
onUpdateParticipation,
@@ -239,6 +240,83 @@ export function getBiddingDetailVendorColumns({
),
},
{
+ accessorKey: 'priceAdjustmentResponse',
+ header: '연동제 응답',
+ cell: ({ row }) => {
+ const vendor = row.original
+ const response = vendor.priceAdjustmentResponse
+
+ // 버튼 형태로 표시, 클릭 시 상세 다이얼로그 열기
+ const getBadgeVariant = () => {
+ if (response === null || response === undefined) return 'outline'
+ return response ? 'default' : 'secondary'
+ }
+
+ const getBadgeClass = () => {
+ if (response === true) return 'bg-green-600 hover:bg-green-700 cursor-pointer'
+ if (response === false) return 'hover:bg-gray-300 cursor-pointer'
+ return ''
+ }
+
+ const getLabel = () => {
+ if (response === null || response === undefined) return '해당없음'
+ return response ? '예' : '아니오'
+ }
+
+ return (
+ <Badge
+ variant={getBadgeVariant()}
+ className={getBadgeClass()}
+ onClick={() => onViewPriceAdjustment?.(vendor)}
+ >
+ {getLabel()}
+ </Badge>
+ )
+ },
+ },
+ {
+ accessorKey: 'shiPriceAdjustmentApplied',
+ header: 'SHI연동제적용',
+ cell: ({ row }) => {
+ const applied = row.original.shiPriceAdjustmentApplied
+ if (applied === null || applied === undefined) {
+ return <Badge variant="outline">미정</Badge>
+ }
+ return (
+ <Badge variant={applied ? 'default' : 'secondary'} className={applied ? 'bg-green-600' : ''}>
+ {applied ? '적용' : '미적용'}
+ </Badge>
+ )
+ },
+ },
+ {
+ accessorKey: 'priceAdjustmentNote',
+ header: '연동제 Note',
+ cell: ({ row }) => {
+ const note = row.original.priceAdjustmentNote
+ return (
+ <div className="text-sm max-w-[150px] truncate" title={note || ''}>
+ {note || '-'}
+ </div>
+ )
+ },
+ },
+ {
+ accessorKey: 'hasChemicalSubstance',
+ header: '화학물질',
+ cell: ({ row }) => {
+ const hasChemical = row.original.hasChemicalSubstance
+ if (hasChemical === null || hasChemical === undefined) {
+ return <Badge variant="outline">미정</Badge>
+ }
+ return (
+ <Badge variant={hasChemical ? 'destructive' : 'secondary'}>
+ {hasChemical ? '해당' : '해당없음'}
+ </Badge>
+ )
+ },
+ },
+ {
id: 'actions',
header: '작업',
cell: ({ row }) => {
diff --git a/lib/bidding/detail/table/bidding-detail-vendor-table.tsx b/lib/bidding/detail/table/bidding-detail-vendor-table.tsx
index fffac0c1..407cc51c 100644
--- a/lib/bidding/detail/table/bidding-detail-vendor-table.tsx
+++ b/lib/bidding/detail/table/bidding-detail-vendor-table.tsx
@@ -10,9 +10,9 @@ import { BiddingDetailVendorToolbarActions } from './bidding-detail-vendor-toolb
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 { QuotationVendor } from '@/lib/bidding/detail/service'
import { Bidding } from '@/db/schema'
-import { PriceAdjustmentDialog } from '@/components/bidding/price-adjustment-dialog'
+import { VendorPriceAdjustmentViewDialog } from './vendor-price-adjustment-view-dialog'
import { QuotationHistoryDialog } from './quotation-history-dialog'
import { ApprovalPreviewDialog } from '@/lib/approval/approval-preview-dialog'
import { ApplicationReasonDialog } from '@/lib/rfq-last/vendor/application-reason-dialog'
@@ -27,6 +27,7 @@ interface BiddingDetailVendorTableContentProps {
onOpenSelectionReasonDialog: () => void
onViewItemDetails?: (vendor: QuotationVendor) => void
onViewQuotationHistory?: (vendor: QuotationVendor) => void
+ readOnly?: boolean
}
const filterFields: DataTableFilterField<QuotationVendor>[] = [
@@ -86,7 +87,8 @@ export function BiddingDetailVendorTableContent({
vendors,
onRefresh,
onViewItemDetails,
- onViewQuotationHistory
+ onViewQuotationHistory,
+ readOnly = false
}: BiddingDetailVendorTableContentProps) {
const { data: session } = useSession()
const { toast } = useToast()
@@ -96,8 +98,7 @@ export function BiddingDetailVendorTableContent({
const [selectedVendor, setSelectedVendor] = React.useState<QuotationVendor | null>(null)
const [isAwardDialogOpen, setIsAwardDialogOpen] = React.useState(false)
const [isAwardRatioDialogOpen, setIsAwardRatioDialogOpen] = React.useState(false)
- const [priceAdjustmentData, setPriceAdjustmentData] = React.useState<any>(null)
- const [isPriceAdjustmentDialogOpen, setIsPriceAdjustmentDialogOpen] = React.useState(false)
+ const [isVendorPriceAdjustmentDialogOpen, setIsVendorPriceAdjustmentDialogOpen] = React.useState(false)
const [quotationHistoryData, setQuotationHistoryData] = React.useState<any>(null)
const [isQuotationHistoryDialogOpen, setIsQuotationHistoryDialogOpen] = React.useState(false)
const [approvalPreviewData, setApprovalPreviewData] = React.useState<{
@@ -114,28 +115,9 @@ export function BiddingDetailVendorTableContent({
} | null>(null)
const [isApprovalPreviewDialogOpen, setIsApprovalPreviewDialogOpen] = React.useState(false)
- const handleViewPriceAdjustment = async (vendor: QuotationVendor) => {
- try {
- const priceAdjustmentForm = await getPriceAdjustmentFormByBiddingCompanyId(vendor.id)
- if (priceAdjustmentForm) {
- setPriceAdjustmentData(priceAdjustmentForm)
- setSelectedVendor(vendor)
- setIsPriceAdjustmentDialogOpen(true)
- } else {
- toast({
- title: '연동제 정보 없음',
- description: '해당 업체의 연동제 정보가 없습니다.',
- variant: 'default',
- })
- }
- } catch (error) {
- console.error('Failed to load price adjustment form:', error)
- toast({
- title: '오류',
- description: '연동제 정보를 불러오는데 실패했습니다.',
- variant: 'destructive',
- })
- }
+ const handleViewPriceAdjustment = (vendor: QuotationVendor) => {
+ setSelectedVendor(vendor)
+ setIsVendorPriceAdjustmentDialogOpen(true)
}
const handleViewQuotationHistory = async (vendor: QuotationVendor) => {
@@ -269,6 +251,7 @@ export function BiddingDetailVendorTableContent({
onSuccess={onRefresh}
winnerVendor={vendors.find(v => v.awardRatio === 100)}
singleSelectedVendor={singleSelectedVendor}
+ readOnly={readOnly}
/>
</DataTableAdvancedToolbar>
</DataTable>
@@ -296,11 +279,12 @@ export function BiddingDetailVendorTableContent({
}}
/>
- <PriceAdjustmentDialog
- open={isPriceAdjustmentDialogOpen}
- onOpenChange={setIsPriceAdjustmentDialogOpen}
- data={priceAdjustmentData}
+ <VendorPriceAdjustmentViewDialog
+ open={isVendorPriceAdjustmentDialogOpen}
+ onOpenChange={setIsVendorPriceAdjustmentDialogOpen}
vendorName={selectedVendor?.vendorName || ''}
+ priceAdjustmentResponse={selectedVendor?.priceAdjustmentResponse ?? null}
+ biddingCompanyId={selectedVendor?.id || 0}
/>
<QuotationHistoryDialog
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 8df29289..e934a5fe 100644
--- a/lib/bidding/detail/table/bidding-detail-vendor-toolbar-actions.tsx
+++ b/lib/bidding/detail/table/bidding-detail-vendor-toolbar-actions.tsx
@@ -5,13 +5,14 @@ import { useRouter } from "next/navigation"
import { useTransition } from "react"
import { Button } from "@/components/ui/button"
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog"
-import { Plus, Send, RotateCcw, XCircle, Trophy, FileText, DollarSign, RotateCw } from "lucide-react"
+import { Plus, Send, RotateCcw, XCircle, Trophy, FileText, DollarSign, RotateCw, Link2 } from "lucide-react"
import { registerBidding, markAsDisposal, cancelAwardRatio } from "@/lib/bidding/detail/service"
import { sendBiddingBasicContracts, getSelectedVendorsForBidding } from "@/lib/bidding/pre-quote/service"
import { increaseRoundOrRebid } from "@/lib/bidding/service"
import { BiddingDetailVendorCreateDialog } from "../../../../components/bidding/manage/bidding-detail-vendor-create-dialog"
import { BiddingDocumentUploadDialog } from "./bidding-document-upload-dialog"
+import { PriceAdjustmentDialog } from "./price-adjustment-dialog"
import { Bidding } from "@/db/schema"
import { useToast } from "@/hooks/use-toast"
import { QuotationVendor } from "@/lib/bidding/detail/service"
@@ -25,6 +26,7 @@ interface BiddingDetailVendorToolbarActionsProps {
onSuccess: () => void
winnerVendor?: QuotationVendor | null // 100% 낙찰된 벤더
singleSelectedVendor?: QuotationVendor | null // single select된 벤더
+ readOnly?: boolean
}
export function BiddingDetailVendorToolbarActions({
@@ -35,7 +37,8 @@ export function BiddingDetailVendorToolbarActions({
onOpenAwardRatioDialog,
onSuccess,
winnerVendor,
- singleSelectedVendor
+ singleSelectedVendor,
+ readOnly = false
}: BiddingDetailVendorToolbarActionsProps) {
const router = useRouter()
const { toast } = useToast()
@@ -47,6 +50,7 @@ export function BiddingDetailVendorToolbarActions({
const [selectedVendors, setSelectedVendors] = React.useState<any[]>([])
const [isRoundIncreaseDialogOpen, setIsRoundIncreaseDialogOpen] = React.useState(false)
const [isCancelAwardDialogOpen, setIsCancelAwardDialogOpen] = React.useState(false)
+ const [isPriceAdjustmentDialogOpen, setIsPriceAdjustmentDialogOpen] = React.useState(false)
// 본입찰 초대 다이얼로그가 열릴 때 선정된 업체들 조회
React.useEffect(() => {
@@ -82,53 +86,6 @@ export function BiddingDetailVendorToolbarActions({
setIsBiddingInvitationDialogOpen(true)
}
- // const handleBiddingInvitationSend = async (data: any) => {
- // try {
- // // 1. 기본계약 발송
- // const contractResult = await sendBiddingBasicContracts(
- // biddingId,
- // data.vendors,
- // data.generatedPdfs,
- // data.message
- // )
-
- // if (!contractResult.success) {
- // toast({
- // title: '기본계약 발송 실패',
- // description: contractResult.error,
- // variant: 'destructive',
- // })
- // return
- // }
-
- // // 2. 입찰 등록 진행
- // const registerResult = await registerBidding(bidding.id, userId)
-
- // if (registerResult.success) {
- // toast({
- // title: '본입찰 초대 완료',
- // description: '기본계약 발송 및 본입찰 초대가 완료되었습니다.',
- // })
- // setIsBiddingInvitationDialogOpen(false)
- // router.refresh()
- // onSuccess()
- // } else {
- // toast({
- // title: '오류',
- // description: registerResult.error,
- // variant: 'destructive',
- // })
- // }
- // } catch (error) {
- // console.error('본입찰 초대 실패:', error)
- // toast({
- // title: '오류',
- // description: '본입찰 초대에 실패했습니다.',
- // variant: 'destructive',
- // })
- // }
- // }
-
// 선정된 업체들 조회 (서버 액션 함수 사용)
const getSelectedVendors = async () => {
try {
@@ -165,27 +122,6 @@ export function BiddingDetailVendorToolbarActions({
})
}
- const handleRoundIncrease = () => {
- startTransition(async () => {
- const result = await increaseRoundOrRebid(bidding.id, userId, 'round_increase')
-
- if (result.success) {
- toast({
- title: "성공",
- description: result.message,
- })
- router.push(`/evcp/bid`)
- onSuccess()
- } else {
- toast({
- title: "오류",
- description: result.error || "차수증가 중 오류가 발생했습니다.",
- variant: 'destructive',
- })
- }
- })
- }
-
const handleCancelAward = () => {
if (!winnerVendor) return
@@ -218,8 +154,12 @@ export function BiddingDetailVendorToolbarActions({
title: "성공",
description: '차수증가가 완료되었습니다.',
})
- router.push(`/evcp/bid`)
- onSuccess()
+ if (result.biddingId) {
+ router.push(`/evcp/bid/${result.biddingId}/info`)
+ } else {
+ router.push(`/evcp/bid`)
+ }
+ // onSuccess()
} else {
toast({
title: "오류",
@@ -233,69 +173,87 @@ export function BiddingDetailVendorToolbarActions({
return (
<>
<div className="flex items-center gap-2">
- {/* 상태별 액션 버튼 */}
- {/* 차수증가: 입찰공고 또는 입찰평가중 상태에서만 */}
- {(bidding.status === 'evaluation_of_bidding' || bidding.status === 'bidding_opened') && (
- <Button
- variant="outline"
- size="sm"
- onClick={() => setIsRoundIncreaseDialogOpen(true)}
- disabled={isPending}
- >
- <RotateCw className="mr-2 h-4 w-4" />
- 차수증가
- </Button>
- )}
-
- {/* 발주비율 산정: single select 시에만 활성화 */}
- {(bidding.status === 'evaluation_of_bidding') && (
- <Button
- variant="outline"
- size="sm"
- onClick={onOpenAwardRatioDialog}
- disabled={!singleSelectedVendor || isPending || singleSelectedVendor.isBiddingParticipated !== true}
- >
- <DollarSign className="mr-2 h-4 w-4" />
- 발주비율 산정
- </Button>
- )}
-
- {/* 유찰/낙찰: 입찰공고 또는 입찰평가중 상태에서만 */}
- {(bidding.status === 'bidding_opened' || bidding.status === 'evaluation_of_bidding') && (
+ {/* 상태별 액션 버튼 - 읽기 전용이 아닐 때만 표시 */}
+ {!readOnly && (
<>
- <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 === 'evaluation_of_bidding' || bidding.status === 'bidding_opened') && (
+ <Button
+ variant="outline"
+ size="sm"
+ onClick={() => setIsRoundIncreaseDialogOpen(true)}
+ disabled={isPending}
+ >
+ <RotateCw className="mr-2 h-4 w-4" />
+ 차수증가
+ </Button>
+ )}
+
+ {/* 발주비율 산정: single select 시에만 활성화 */}
+ {(bidding.status === 'evaluation_of_bidding') && (
+ <Button
+ variant="outline"
+ size="sm"
+ onClick={onOpenAwardRatioDialog}
+ disabled={!singleSelectedVendor || isPending || singleSelectedVendor.isBiddingParticipated !== true}
+ >
+ <DollarSign className="mr-2 h-4 w-4" />
+ 발주비율 산정
+ </Button>
+ )}
+
+ {/* 연동제 적용여부: single select 시에만 활성화 */}
+ {(bidding.status === 'evaluation_of_bidding') && (
+ <Button
+ variant="outline"
+ size="sm"
+ onClick={() => setIsPriceAdjustmentDialogOpen(true)}
+ disabled={!singleSelectedVendor || isPending || singleSelectedVendor.isBiddingParticipated !== true}
+ >
+ <Link2 className="mr-2 h-4 w-4" />
+ 연동제 적용
+ </Button>
+ )}
+
+ {/* 유찰/낙찰: 입찰공고 또는 입찰평가중 상태에서만 */}
+ {(bidding.status === 'bidding_opened' || bidding.status === 'evaluation_of_bidding') && (
+ <>
+ <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>
+ </>
+ )}
+
+ {/* 발주비율 취소: 100% 낙찰된 벤더가 있는 경우 */}
+ {winnerVendor && (
+ <Button
+ variant="outline"
+ size="sm"
+ onClick={() => setIsCancelAwardDialogOpen(true)}
+ disabled={isPending}
+ >
+ <RotateCcw className="mr-2 h-4 w-4" />
+ 발주비율 취소
+ </Button>
+ )}
</>
)}
- {/* 발주비율 취소: 100% 낙찰된 벤더가 있는 경우 */}
- {winnerVendor && (
- <Button
- variant="outline"
- size="sm"
- onClick={() => setIsCancelAwardDialogOpen(true)}
- disabled={isPending}
- >
- <RotateCcw className="mr-2 h-4 w-4" />
- 발주비율 취소
- </Button>
- )}
{/* 구분선 */}
{(bidding.status === 'bidding_generated' ||
bidding.status === 'bidding_disposal') && (
@@ -392,6 +350,14 @@ export function BiddingDetailVendorToolbarActions({
</DialogContent>
</Dialog>
+ {/* 연동제 적용여부 다이얼로그 */}
+ <PriceAdjustmentDialog
+ open={isPriceAdjustmentDialogOpen}
+ onOpenChange={setIsPriceAdjustmentDialogOpen}
+ vendor={singleSelectedVendor || null}
+ onSuccess={onSuccess}
+ />
+
</>
)
}
diff --git a/lib/bidding/detail/table/price-adjustment-dialog.tsx b/lib/bidding/detail/table/price-adjustment-dialog.tsx
new file mode 100644
index 00000000..96a3af0c
--- /dev/null
+++ b/lib/bidding/detail/table/price-adjustment-dialog.tsx
@@ -0,0 +1,195 @@
+"use client"
+
+import * as React 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 { Switch } from "@/components/ui/switch"
+import { useToast } from "@/hooks/use-toast"
+import { updatePriceAdjustmentInfo } from "@/lib/bidding/detail/service"
+import { QuotationVendor } from "@/lib/bidding/detail/service"
+import { Loader2 } from "lucide-react"
+
+interface PriceAdjustmentDialogProps {
+ open: boolean
+ onOpenChange: (open: boolean) => void
+ vendor: QuotationVendor | null
+ onSuccess: () => void
+}
+
+export function PriceAdjustmentDialog({
+ open,
+ onOpenChange,
+ vendor,
+ onSuccess,
+}: PriceAdjustmentDialogProps) {
+ const { toast } = useToast()
+ const [isSubmitting, setIsSubmitting] = React.useState(false)
+
+ // 폼 상태
+ const [shiPriceAdjustmentApplied, setSHIPriceAdjustmentApplied] = React.useState<boolean | null>(null)
+ const [priceAdjustmentNote, setPriceAdjustmentNote] = React.useState("")
+ const [hasChemicalSubstance, setHasChemicalSubstance] = React.useState<boolean | null>(null)
+
+ // 다이얼로그가 열릴 때 벤더 정보로 폼 초기화
+ React.useEffect(() => {
+ if (open && vendor) {
+ setSHIPriceAdjustmentApplied(vendor.shiPriceAdjustmentApplied ?? null)
+ setPriceAdjustmentNote(vendor.priceAdjustmentNote || "")
+ setHasChemicalSubstance(vendor.hasChemicalSubstance ?? null)
+ }
+ }, [open, vendor])
+
+ const handleSubmit = async () => {
+ if (!vendor) return
+
+ setIsSubmitting(true)
+ try {
+ const result = await updatePriceAdjustmentInfo({
+ biddingCompanyId: vendor.id,
+ shiPriceAdjustmentApplied,
+ priceAdjustmentNote: priceAdjustmentNote || null,
+ hasChemicalSubstance,
+ })
+
+ if (result.success) {
+ toast({
+ title: "저장 완료",
+ description: "연동제 정보가 저장되었습니다.",
+ })
+ onOpenChange(false)
+ onSuccess()
+ } else {
+ toast({
+ title: "오류",
+ description: result.error || "저장 중 오류가 발생했습니다.",
+ variant: "destructive",
+ })
+ }
+ } catch (error) {
+ console.error("연동제 정보 저장 오류:", error)
+ toast({
+ title: "오류",
+ description: "저장 중 오류가 발생했습니다.",
+ variant: "destructive",
+ })
+ } finally {
+ setIsSubmitting(false)
+ }
+ }
+
+ if (!vendor) return null
+
+ return (
+ <Dialog open={open} onOpenChange={onOpenChange}>
+ <DialogContent className="sm:max-w-[500px]">
+ <DialogHeader>
+ <DialogTitle>연동제 적용 설정</DialogTitle>
+ <DialogDescription>
+ <span className="font-semibold text-primary">{vendor.vendorName}</span> 업체의 연동제 적용 여부를 설정합니다.
+ </DialogDescription>
+ </DialogHeader>
+
+ <div className="space-y-6 py-4">
+ {/* 업체가 제출한 연동제 요청 여부 (읽기 전용) */}
+ {/* <div className="flex flex-row items-center justify-between rounded-lg border p-4 bg-muted/50">
+ <div className="space-y-0.5">
+ <Label className="text-base">업체 연동제 요청</Label>
+ <p className="text-sm text-muted-foreground">
+ 업체가 제출한 연동제 적용 요청 여부입니다.
+ </p>
+ </div>
+ <span className={`font-medium ${vendor.isPriceAdjustmentApplicableQuestion ? 'text-green-600' : 'text-gray-500'}`}>
+ {vendor.isPriceAdjustmentApplicableQuestion === null ? '미정' : vendor.isPriceAdjustmentApplicableQuestion ? '예' : '아니오'}
+ </span>
+ </div> */}
+
+ {/* SHI 연동제 적용여부 */}
+ <div className="flex flex-row items-center justify-between rounded-lg border p-4">
+ <div className="space-y-0.5">
+ <Label className="text-base">SHI 연동제 적용</Label>
+ <p className="text-sm text-muted-foreground">
+ 해당 업체에 연동제를 적용할지 결정합니다.
+ </p>
+ </div>
+ <div className="flex items-center gap-3">
+ <span className={`text-sm ${shiPriceAdjustmentApplied === false ? 'font-medium' : 'text-muted-foreground'}`}>
+ 미적용
+ </span>
+ <Switch
+ checked={shiPriceAdjustmentApplied === true}
+ onCheckedChange={(checked) => setSHIPriceAdjustmentApplied(checked)}
+ />
+ <span className={`text-sm ${shiPriceAdjustmentApplied === true ? 'font-medium' : 'text-muted-foreground'}`}>
+ 적용
+ </span>
+ </div>
+ </div>
+
+ {/* 연동제 Note */}
+ <div className="space-y-2">
+ <Label htmlFor="price-adjustment-note">연동제 Note</Label>
+ <Textarea
+ id="price-adjustment-note"
+ placeholder="연동제 관련 추가 사항을 입력하세요"
+ value={priceAdjustmentNote}
+ onChange={(e) => setPriceAdjustmentNote(e.target.value)}
+ rows={4}
+ />
+ </div>
+
+ {/* 화학물질 여부 */}
+ {/* <div className="flex flex-row items-center justify-between rounded-lg border p-4">
+ <div className="space-y-0.5">
+ <Label className="text-base">화학물질 해당여부</Label>
+ <p className="text-sm text-muted-foreground">
+ 해당 업체가 화학물질 취급 대상인지 여부입니다.
+ </p>
+ </div>
+ <div className="flex items-center gap-3">
+ <span className={`text-sm ${hasChemicalSubstance === false ? 'font-medium' : 'text-muted-foreground'}`}>
+ 해당없음
+ </span>
+ <Switch
+ checked={hasChemicalSubstance === true}
+ onCheckedChange={(checked) => setHasChemicalSubstance(checked)}
+ />
+ <span className={`text-sm ${hasChemicalSubstance === true ? 'font-medium text-red-600' : 'text-muted-foreground'}`}>
+ 해당
+ </span>
+ </div>
+ </div> */}
+ </div>
+
+ <DialogFooter>
+ <Button
+ variant="outline"
+ onClick={() => onOpenChange(false)}
+ disabled={isSubmitting}
+ >
+ 취소
+ </Button>
+ <Button onClick={handleSubmit} disabled={isSubmitting}>
+ {isSubmitting ? (
+ <>
+ <Loader2 className="mr-2 h-4 w-4 animate-spin" />
+ 저장 중...
+ </>
+ ) : (
+ "저장"
+ )}
+ </Button>
+ </DialogFooter>
+ </DialogContent>
+ </Dialog>
+ )
+}
+
diff --git a/lib/bidding/detail/table/vendor-price-adjustment-view-dialog.tsx b/lib/bidding/detail/table/vendor-price-adjustment-view-dialog.tsx
new file mode 100644
index 00000000..f31caf5e
--- /dev/null
+++ b/lib/bidding/detail/table/vendor-price-adjustment-view-dialog.tsx
@@ -0,0 +1,324 @@
+'use client'
+
+import React from 'react'
+import {
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogHeader,
+ DialogTitle,
+} from '@/components/ui/dialog'
+import { Badge } from '@/components/ui/badge'
+import { Separator } from '@/components/ui/separator'
+import { format } from 'date-fns'
+import { ko } from 'date-fns/locale'
+import { Loader2 } from 'lucide-react'
+
+interface PriceAdjustmentData {
+ id: number
+ itemName?: string | null
+ adjustmentReflectionPoint?: string | null
+ majorApplicableRawMaterial?: string | null
+ adjustmentFormula?: string | null
+ rawMaterialPriceIndex?: string | null
+ referenceDate?: Date | string | null
+ comparisonDate?: Date | string | null
+ adjustmentRatio?: string | null
+ notes?: string | null
+ adjustmentConditions?: string | null
+ majorNonApplicableRawMaterial?: string | null
+ adjustmentPeriod?: string | null
+ contractorWriter?: string | null
+ adjustmentDate?: Date | string | null
+ nonApplicableReason?: string | null
+ createdAt: Date | string
+ updatedAt: Date | string
+}
+
+interface VendorPriceAdjustmentViewDialogProps {
+ open: boolean
+ onOpenChange: (open: boolean) => void
+ vendorName: string
+ priceAdjustmentResponse: boolean | null // 벤더가 응답한 연동제 적용 여부
+ biddingCompanyId: number
+}
+
+export function VendorPriceAdjustmentViewDialog({
+ open,
+ onOpenChange,
+ vendorName,
+ priceAdjustmentResponse,
+ biddingCompanyId,
+}: VendorPriceAdjustmentViewDialogProps) {
+ const [data, setData] = React.useState<PriceAdjustmentData | null>(null)
+ const [isLoading, setIsLoading] = React.useState(false)
+ const [error, setError] = React.useState<string | null>(null)
+
+ // 다이얼로그가 열릴 때 데이터 로드
+ React.useEffect(() => {
+ if (open && biddingCompanyId) {
+ loadPriceAdjustmentData()
+ }
+ }, [open, biddingCompanyId])
+
+ const loadPriceAdjustmentData = async () => {
+ setIsLoading(true)
+ setError(null)
+ try {
+ // 서버에서 연동제 폼 데이터 조회
+ const { getPriceAdjustmentFormByBiddingCompanyId } = await import('@/lib/bidding/detail/service')
+ const formData = await getPriceAdjustmentFormByBiddingCompanyId(biddingCompanyId)
+ setData(formData)
+ } catch (err) {
+ console.error('Failed to load price adjustment data:', err)
+ setError('연동제 정보를 불러오는데 실패했습니다.')
+ } finally {
+ setIsLoading(false)
+ }
+ }
+
+ // 날짜 포맷팅 헬퍼
+ const formatDateValue = (date: Date | string | null | undefined) => {
+ if (!date) return '-'
+ try {
+ const dateObj = typeof date === 'string' ? new Date(date) : date
+ return format(dateObj, 'yyyy-MM-dd', { locale: ko })
+ } catch {
+ return '-'
+ }
+ }
+
+ // 연동제 적용 여부 판단
+ const isApplied = priceAdjustmentResponse === true
+ const isNotApplied = priceAdjustmentResponse === false
+
+ return (
+ <Dialog open={open} onOpenChange={onOpenChange}>
+ <DialogContent className="max-w-4xl max-h-[80vh] overflow-y-auto">
+ <DialogHeader>
+ <DialogTitle className="flex items-center gap-2">
+ <span>하도급대금등 연동표</span>
+ <Badge variant="secondary">{vendorName}</Badge>
+ {isApplied && (
+ <Badge variant="default" className="bg-green-600 hover:bg-green-700">
+ 연동제 적용
+ </Badge>
+ )}
+ {isNotApplied && (
+ <Badge variant="outline" className="border-red-500 text-red-600">
+ 연동제 미적용
+ </Badge>
+ )}
+ {priceAdjustmentResponse === null && (
+ <Badge variant="outline">해당없음</Badge>
+ )}
+ </DialogTitle>
+ <DialogDescription>
+ 협력업체가 제출한 연동제 적용 정보입니다.
+ {isApplied && " (연동제 적용)"}
+ {isNotApplied && " (연동제 미적용)"}
+ </DialogDescription>
+ </DialogHeader>
+
+ {isLoading ? (
+ <div className="flex items-center justify-center py-12">
+ <Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
+ <span className="ml-2 text-muted-foreground">연동제 정보를 불러오는 중...</span>
+ </div>
+ ) : error ? (
+ <div className="py-8 text-center text-red-600">{error}</div>
+ ) : !data && priceAdjustmentResponse !== null ? (
+ <div className="py-8 text-center text-muted-foreground">연동제 상세 정보가 없습니다.</div>
+ ) : priceAdjustmentResponse === null ? (
+ <div className="py-8 text-center text-muted-foreground">해당 업체는 연동제 관련 응답을 하지 않았습니다.</div>
+ ) : (
+ <div className="space-y-6">
+ {/* 기본 정보 */}
+ <div>
+ <h3 className="text-sm font-medium text-gray-900 mb-3">기본 정보</h3>
+ <div className="grid grid-cols-2 gap-4">
+ <div>
+ <label className="text-xs text-gray-500">물품등의 명칭</label>
+ <p className="text-sm font-medium">{data?.itemName || '-'}</p>
+ </div>
+ <div>
+ <label className="text-xs text-gray-500">연동제 적용 여부</label>
+ <div className="mt-1">
+ {isApplied && (
+ <Badge variant="default" className="bg-green-600 hover:bg-green-700">
+ 예 (연동제 적용)
+ </Badge>
+ )}
+ {isNotApplied && (
+ <Badge variant="outline" className="border-red-500 text-red-600">
+ 아니오 (연동제 미적용)
+ </Badge>
+ )}
+ </div>
+ </div>
+ {isApplied && (
+ <div>
+ <label className="text-xs text-gray-500">조정대금 반영시점</label>
+ <p className="text-sm font-medium">{data?.adjustmentReflectionPoint || '-'}</p>
+ </div>
+ )}
+ </div>
+ </div>
+
+ <Separator />
+
+ {/* 원재료 정보 */}
+ <div>
+ <h3 className="text-sm font-medium text-gray-900 mb-3">원재료 정보</h3>
+ <div className="space-y-4">
+ {isApplied && (
+ <div>
+ <label className="text-xs text-gray-500">연동대상 주요 원재료</label>
+ <p className="text-sm font-medium whitespace-pre-wrap">
+ {data?.majorApplicableRawMaterial || '-'}
+ </p>
+ </div>
+ )}
+ {isNotApplied && (
+ <>
+ <div>
+ <label className="text-xs text-gray-500">연동 미적용 주요 원재료</label>
+ <p className="text-sm font-medium whitespace-pre-wrap">
+ {data?.majorNonApplicableRawMaterial || '-'}
+ </p>
+ </div>
+ <div>
+ <label className="text-xs text-gray-500">연동 미적용 사유</label>
+ <p className="text-sm font-medium whitespace-pre-wrap">
+ {data?.nonApplicableReason || '-'}
+ </p>
+ </div>
+ </>
+ )}
+ </div>
+ </div>
+
+ {isApplied && data && (
+ <>
+ <Separator />
+
+ {/* 연동 공식 및 지표 */}
+ <div>
+ <h3 className="text-sm font-medium text-gray-900 mb-3">연동 공식 및 지표</h3>
+ <div className="space-y-4">
+ <div>
+ <label className="text-xs text-gray-500">하도급대금등 연동 산식</label>
+ <div className="p-3 bg-gray-50 rounded-md">
+ <p className="text-sm font-mono whitespace-pre-wrap">
+ {data.adjustmentFormula || '-'}
+ </p>
+ </div>
+ </div>
+ <div>
+ <label className="text-xs text-gray-500">원재료 가격 기준지표</label>
+ <p className="text-sm font-medium whitespace-pre-wrap">
+ {data.rawMaterialPriceIndex || '-'}
+ </p>
+ </div>
+ <div className="grid grid-cols-2 gap-4">
+ <div>
+ <label className="text-xs text-gray-500">원재료 기준 가격의 변동률 산정을 위한 기준시점</label>
+ <p className="text-sm font-medium">{data.referenceDate ? formatDateValue(data.referenceDate) : '-'}</p>
+ </div>
+ <div>
+ <label className="text-xs text-gray-500">원재료 기준 가격의 변동률 산정을 위한 비교시점</label>
+ <p className="text-sm font-medium">{data.comparisonDate ? formatDateValue(data.comparisonDate) : '-'}</p>
+ </div>
+ </div>
+ {data.adjustmentRatio && (
+ <div>
+ <label className="text-xs text-gray-500">반영비율</label>
+ <p className="text-sm font-medium">
+ {data.adjustmentRatio}%
+ </p>
+ </div>
+ )}
+ </div>
+ </div>
+
+ <Separator />
+
+ {/* 조정 조건 및 기타 */}
+ <div>
+ <h3 className="text-sm font-medium text-gray-900 mb-3">조정 조건 및 기타</h3>
+ <div className="space-y-4">
+ <div>
+ <label className="text-xs text-gray-500">조정요건</label>
+ <p className="text-sm font-medium whitespace-pre-wrap">
+ {data.adjustmentConditions || '-'}
+ </p>
+ </div>
+ <div className="grid grid-cols-2 gap-4">
+ <div>
+ <label className="text-xs text-gray-500">조정주기</label>
+ <p className="text-sm font-medium">{data.adjustmentPeriod || '-'}</p>
+ </div>
+ <div>
+ <label className="text-xs text-gray-500">조정일</label>
+ <p className="text-sm font-medium">{data.adjustmentDate ? formatDateValue(data.adjustmentDate) : '-'}</p>
+ </div>
+ </div>
+ <div>
+ <label className="text-xs text-gray-500">수탁기업(협력사)작성자</label>
+ <p className="text-sm font-medium">{data.contractorWriter || '-'}</p>
+ </div>
+ {data.notes && (
+ <div>
+ <label className="text-xs text-gray-500">기타사항</label>
+ <p className="text-sm font-medium whitespace-pre-wrap">
+ {data.notes}
+ </p>
+ </div>
+ )}
+ </div>
+ </div>
+ </>
+ )}
+
+ {isNotApplied && data && (
+ <>
+ <Separator />
+ <div>
+ <h3 className="text-sm font-medium text-gray-900 mb-3">작성자 정보</h3>
+ <div>
+ <label className="text-xs text-gray-500">수탁기업(협력사) 작성자</label>
+ <p className="text-sm font-medium">{data.contractorWriter || '-'}</p>
+ </div>
+ </div>
+ </>
+ )}
+
+ {data && (
+ <>
+ <Separator />
+
+ {/* 메타 정보 */}
+ <div className="text-xs text-gray-500 space-y-1">
+ <p>작성일: {formatDateValue(data.createdAt)}</p>
+ <p>수정일: {formatDateValue(data.updatedAt)}</p>
+ </div>
+ </>
+ )}
+
+ <Separator />
+
+ {/* 참고 경고문 */}
+ <div className="text-xs text-red-600 space-y-2 bg-red-50 p-3 rounded-md border border-red-200">
+ <p className="font-medium">※ 참고사항</p>
+ <div className="space-y-1">
+ <p>• 납품대금의 10% 이상을 차지하는 주요 원재료가 있는 경우 모든 주요 원재료에 대해서 적용 또는 미적용에 대한 연동표를 작성해야 한다.</p>
+ <p>• 납품대급연동표를 허위로 작성하거나 근거자료를 허위로 제출할 경우 본 계약이 체결되지 않을 수 있으며, 본 계약이 체결되었더라도 계약의 전부 또는 일부를 해제 또는 해지할 수 있다.</p>
+ </div>
+ </div>
+ </div>
+ )}
+ </DialogContent>
+ </Dialog>
+ )
+}
+