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