diff options
Diffstat (limited to 'lib/bidding')
36 files changed, 4245 insertions, 1417 deletions
diff --git a/lib/bidding/actions.ts b/lib/bidding/actions.ts index 4e7da36c..64dc3aa8 100644 --- a/lib/bidding/actions.ts +++ b/lib/bidding/actions.ts @@ -20,7 +20,7 @@ import { createPurchaseOrder } from "@/lib/soap/ecc/send/create-po-bidding" import { getCurrentSAPDate } from "@/lib/soap/utils" import { generateContractNumber } from "@/lib/general-contracts/service" import { saveFile } from "@/lib/file-stroage" - +import { checkAndSaveChemicalSubstancesForBidding } from "./service" // TO Contract export async function transmitToContract(biddingId: number, userId: number) { try { @@ -125,6 +125,11 @@ export async function transmitToContract(biddingId: number, userId: number) { const contractNumber = await generateContractNumber(safeUserId, biddingData.contractType) console.log('Generated contractNumber:', contractNumber) + // 연동제 여부 변환 (boolean -> Y/N) + const interlockingSystem = biddingCondition?.isPriceAdjustmentApplicable + ? 'Y' + : (biddingCondition?.isPriceAdjustmentApplicable === false ? 'N' : null) + // general-contract 생성 (발주비율 계산된 최종 금액 사용) const contractResult = await db.insert(generalContracts).values({ contractNumber, @@ -141,10 +146,13 @@ export async function transmitToContract(biddingId: number, userId: number) { currency: biddingData.currency || 'KRW', // 계약 조건 정보 추가 paymentTerm: biddingCondition?.paymentTerms || null, + paymentDelivery: biddingCondition?.paymentTerms || null, // 지급조건 (납품 지급조건) taxType: biddingCondition?.taxConditions || 'V0', deliveryTerm: biddingCondition?.incoterms || 'FOB', shippingLocation: biddingCondition?.shippingPort || null, dischargeLocation: biddingCondition?.destinationPort || null, + contractDeliveryDate: biddingCondition?.contractDeliveryDate || null, // 계약납기일 + interlockingSystem: interlockingSystem, // 연동제 여부 registeredById: userId, lastUpdatedById: userId, }).returning({ id: generalContracts.id }) @@ -644,7 +652,7 @@ export async function cancelDisposalAction( } // 사용자 이름 조회 헬퍼 함수 -async function getUserNameById(userId: string): Promise<string> { +export async function getUserNameById(userId: string): Promise<string> { try { const user = await db .select({ name: users.name }) @@ -730,6 +738,26 @@ export async function openBiddingAction(biddingId: number) { }) .where(eq(biddings.id, biddingId)) + // 4. 화학물질 조회 실행 (비동기로 실행해서 개찰 성능에 영향 없도록) + try { + // 개찰 트랜잭션이 완료된 후 화학물질 조회 시작 + setImmediate(async () => { + try { + const result = await checkAndSaveChemicalSubstancesForBidding(biddingId) + if (result.success) { + console.log(`입찰 ${biddingId} 화학물질 조회 완료: ${result.results.filter(r => r.success).length}/${result.results.length}개 업체`) + } else { + console.error(`입찰 ${biddingId} 화학물질 조회 실패:`, result.message) + } + } catch (error) { + console.error(`입찰 ${biddingId} 화학물질 조회 중 오류:`, error) + } + }) + } catch (error) { + // 화학물질 조회 실패해도 개찰은 성공으로 처리 + console.error('화학물질 조회 시작 실패:', error) + } + return { success: true, message: isDeadlinePassed ? '개찰이 완료되었습니다.' : '조기개찰이 완료되었습니다.' } }) diff --git a/lib/bidding/approval-actions.ts b/lib/bidding/approval-actions.ts index 3d07d49c..b4f6f297 100644 --- a/lib/bidding/approval-actions.ts +++ b/lib/bidding/approval-actions.ts @@ -81,6 +81,7 @@ export async function prepareBiddingApprovalData(data: { projectName: biddings.projectName, itemName: biddings.itemName, biddingType: biddings.biddingType, + awardCount: biddings.awardCount, bidPicName: biddings.bidPicName, supplyPicName: biddings.supplyPicName, submissionStartDate: biddings.submissionStartDate, @@ -166,6 +167,7 @@ export async function prepareBiddingApprovalData(data: { ...bidding, projectName: bidding.projectName || undefined, itemName: bidding.itemName || undefined, + awardCount: bidding.awardCount || undefined, bidPicName: bidding.bidPicName || undefined, supplyPicName: bidding.supplyPicName || undefined, targetPrice: bidding.targetPrice ? Number(bidding.targetPrice) : undefined, @@ -264,12 +266,14 @@ export async function requestBiddingInvitationWithApproval(data: { const { default: db } = await import('@/db/db'); const { biddings, biddingCompanies, prItemsForBidding } = await import('@/db/schema'); const { eq } = await import('drizzle-orm'); - + const { getUserNameById } = await import('@/lib/bidding/actions'); + const userName = await getUserNameById(data.currentUser.id.toString()); + await db .update(biddings) .set({ status: 'approval_pending', // 결재 진행중 상태 - updatedBy: String(data.currentUser.id), // id를 string으로 변환 + updatedBy: userName, updatedAt: new Date() }) .where(eq(biddings.id, data.biddingId)); @@ -463,6 +467,7 @@ export async function requestBiddingClosureWithApproval(data: { const { default: db } = await import('@/db/db'); const { biddings } = await import('@/db/schema'); const { eq } = await import('drizzle-orm'); + const { getUserNameById } = await import('@/lib/bidding/actions'); // 유찰상태인지 확인 const biddingResult = await db @@ -485,12 +490,12 @@ export async function requestBiddingClosureWithApproval(data: { // 3. 입찰 상태를 결재 진행중으로 변경 debugLog('[BiddingClosureApproval] 입찰 상태 변경 시작'); - + const userName = await getUserNameById(data.currentUser.id.toString()); await db .update(biddings) .set({ status: 'approval_pending', // 폐찰 결재 진행중 상태 - updatedBy: Number(data.currentUser.id), + updatedBy: userName, updatedAt: new Date() }) .where(eq(biddings.id, data.biddingId)); @@ -691,12 +696,13 @@ export async function requestBiddingAwardWithApproval(data: { const { default: db } = await import('@/db/db'); const { biddings } = await import('@/db/schema'); const { eq } = await import('drizzle-orm'); - + const { getUserNameById } = await import('@/lib/bidding/actions'); + const userName = await getUserNameById(data.currentUser.id.toString()); await db .update(biddings) .set({ status: 'approval_pending', // 낙찰 결재 진행중 상태 - updatedBy: Number(data.currentUser.id), + updatedBy: userName, updatedAt: new Date() }) .where(eq(biddings.id, data.biddingId)); 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> + ) +} + diff --git a/lib/bidding/handlers.ts b/lib/bidding/handlers.ts index 11955a39..b422118d 100644 --- a/lib/bidding/handlers.ts +++ b/lib/bidding/handlers.ts @@ -10,6 +10,96 @@ import { debugLog, debugError, debugSuccess } from '@/lib/debug-utils'; /** + * 결재 완료 시점을 기준으로 입찰서 제출기간 계산 및 업데이트 + * + * 계산 로직: + * - baseDate = 결재완료일 날짜만 (00:00:00) + * - 시작일 = baseDate + submissionStartOffset일 + submissionStartDate의 시:분 + * - 마감일 = 시작일(날짜만) + submissionDurationDays일 + submissionEndDate의 시:분 + */ +async function calculateAndUpdateSubmissionDates(biddingId: number) { + const { default: db } = await import('@/db/db'); + const { biddings } = await import('@/db/schema'); + const { eq } = await import('drizzle-orm'); + + // 현재 입찰 정보 조회 + const biddingInfo = await db + .select({ + submissionStartOffset: biddings.submissionStartOffset, + submissionDurationDays: biddings.submissionDurationDays, + submissionStartDate: biddings.submissionStartDate, // 시간만 저장된 상태 (1970-01-01 HH:MM:00) + submissionEndDate: biddings.submissionEndDate, // 시간만 저장된 상태 (1970-01-01 HH:MM:00) + }) + .from(biddings) + .where(eq(biddings.id, biddingId)) + .limit(1); + + if (biddingInfo.length === 0) { + debugError('[calculateAndUpdateSubmissionDates] 입찰 정보를 찾을 수 없음', { biddingId }); + throw new Error('입찰 정보를 찾을 수 없습니다.'); + } + + const { submissionStartOffset, submissionDurationDays, submissionStartDate, submissionEndDate } = biddingInfo[0]; + + // 필수 값 검증 + if (submissionStartOffset === null || submissionDurationDays === null) { + debugError('[calculateAndUpdateSubmissionDates] 오프셋 값이 설정되지 않음', { submissionStartOffset, submissionDurationDays }); + throw new Error('입찰서 제출기간 오프셋이 설정되지 않았습니다.'); + } + + // 시간 추출 (기본값: 시작 09:00, 마감 18:00) + 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 }; + + // 1. baseDate = 결재완료일 날짜만 (KST 기준 00:00:00) + const now = new Date(); + const baseDate = new Date(now); + // KST 기준으로 날짜만 추출 (시간은 00:00:00) + baseDate.setHours(0, 0, 0, 0); + + // 2. 시작일 = baseDate + offset일 + 시작시간 + const calculatedStartDate = new Date(baseDate); + calculatedStartDate.setDate(calculatedStartDate.getDate() + submissionStartOffset); + calculatedStartDate.setHours(startTime.hours, startTime.minutes, 0, 0); + + // 3. 마감일 = 시작일(날짜만) + duration일 + 마감시간 + const calculatedEndDate = new Date(calculatedStartDate); + calculatedEndDate.setHours(0, 0, 0, 0); // 시작일의 날짜만 + calculatedEndDate.setDate(calculatedEndDate.getDate() + submissionDurationDays); + calculatedEndDate.setHours(endTime.hours, endTime.minutes, 0, 0); + + debugLog('[calculateAndUpdateSubmissionDates] 입찰서 제출기간 계산 완료', { + biddingId, + baseDate: baseDate.toISOString(), + submissionStartOffset, + submissionDurationDays, + startTime, + endTime, + calculatedStartDate: calculatedStartDate.toISOString(), + calculatedEndDate: calculatedEndDate.toISOString(), + }); + + // DB 업데이트 + await db + .update(biddings) + .set({ + submissionStartDate: calculatedStartDate, + submissionEndDate: calculatedEndDate, + updatedAt: new Date(), + }) + .where(eq(biddings.id, biddingId)); + + return { + startDate: calculatedStartDate, + endDate: calculatedEndDate, + }; +} + +/** * 입찰초대 핸들러 (결재 승인 후 실행됨) * * ✅ Internal 함수: 결재 워크플로우에서 자동 호출됨 (직접 호출 금지) @@ -52,7 +142,7 @@ export async function requestBiddingInvitationInternal(payload: { try { // 1. 기본계약 발송 const { sendBiddingBasicContracts } = await import('@/lib/bidding/pre-quote/service'); - + const vendorDataForContract = payload.vendors.map(vendor => ({ vendorId: vendor.vendorId, vendorName: vendor.vendorName, @@ -86,7 +176,7 @@ export async function requestBiddingInvitationInternal(payload: { debugLog('[BiddingInvitationHandler] 기본계약 발송 완료'); - // 2. 입찰 등록 진행 (상태를 bidding_opened로 변경) + // 2. 입찰 등록 진행 (상태를 bidding_opened로 변경, 입찰서 제출기간 자동 계산) const { registerBidding } = await import('@/lib/bidding/detail/service'); const registerResult = await registerBidding(payload.biddingId, payload.currentUserId.toString()); @@ -127,6 +217,7 @@ export async function mapBiddingInvitationToTemplateVariables(payload: { biddingNumber: string; projectName?: string; itemName?: string; + awardCount: string; biddingType: string; bidPicName?: string; supplyPicName?: string; @@ -181,7 +272,7 @@ export async function mapBiddingInvitationToTemplateVariables(payload: { const { bidding, biddingItems, vendors, message, specificationMeeting, requestedAt } = payload; // 제목 - const title = bidding.title || '입찰'; + const title = bidding.title || ''; // 입찰명 const biddingTitle = bidding.title || ''; @@ -190,7 +281,7 @@ export async function mapBiddingInvitationToTemplateVariables(payload: { const biddingNumber = bidding.biddingNumber || ''; // 낙찰업체수 - const winnerCount = '1'; // 기본값, 실제로는 bidding 설정에서 가져와야 함 + const awardCount = bidding.awardCount || ''; // 계약구분 const contractType = bidding.biddingType || ''; @@ -199,7 +290,7 @@ export async function mapBiddingInvitationToTemplateVariables(payload: { const prNumber = ''; // 예산 - const budget = bidding.targetPrice ? bidding.targetPrice.toLocaleString() : ''; + const budget = bidding.budget ? bidding.budget.toLocaleString() : ''; // 내정가 const targetPrice = bidding.targetPrice ? bidding.targetPrice.toLocaleString() : ''; @@ -219,9 +310,6 @@ export async function mapBiddingInvitationToTemplateVariables(payload: { // 입찰 공고문 const biddingNotice = message || ''; - // 입찰담당자 (중복이지만 템플릿에 맞춤) - const biddingManagerDup = bidding.bidPicName || bidding.supplyPicName || ''; - // 협력사 정보들 const vendorVariables: Record<string, string> = {}; vendors.forEach((vendor, index) => { @@ -237,8 +325,6 @@ export async function mapBiddingInvitationToTemplateVariables(payload: { const hasSpecMeeting = bidding.hasSpecificationMeeting ? '예' : '아니오'; const specMeetingStart = bidding.submissionStartDate ? new Date(bidding.submissionStartDate).toISOString().slice(0, 16).replace('T', ' ') : ''; const specMeetingEnd = bidding.submissionEndDate ? new Date(bidding.submissionEndDate).toISOString().slice(0, 16).replace('T', ' ') : ''; - const specMeetingStartDup = specMeetingStart; - const specMeetingEndDup = specMeetingEnd; // 입찰서제출기간 정보 const submissionPeriodExecution = '예'; // 입찰 기간이 있으므로 예 @@ -272,7 +358,7 @@ export async function mapBiddingInvitationToTemplateVariables(payload: { 제목: title, 입찰명: biddingTitle, 입찰번호: biddingNumber, - 낙찰업체수: winnerCount, + 낙찰업체수: awardCount, 계약구분: contractType, 'P/R번호': prNumber, 예산: budget, @@ -426,12 +512,13 @@ export async function requestBiddingClosureInternal(payload: { const { default: db } = await import('@/db/db'); const { biddings } = await import('@/db/schema'); const { eq } = await import('drizzle-orm'); - + const { getUserNameById } = await import('@/lib/bidding/actions'); + const userName = await getUserNameById(payload.currentUserId.toString()); await db .update(biddings) .set({ status: 'bid_closure', - updatedBy: payload.currentUserId.toString(), + updatedBy: userName, updatedAt: new Date(), remarks: payload.description, // 폐찰 사유를 remarks에 저장 }) @@ -618,6 +705,15 @@ export async function mapBiddingAwardToTemplateVariables(payload: { biddingId: number; selectionReason: string; requestedAt: Date; + awardedCompanies?: Array<{ + companyId: number; + companyName: string | null; + finalQuoteAmount: number; + awardRatio: number; + vendorCode?: string | null; + companySize?: string | null; + targetPrice?: number | null; + }>; }): Promise<Record<string, string>> { const { biddingId, selectionReason, requestedAt } = payload; @@ -637,6 +733,7 @@ export async function mapBiddingAwardToTemplateVariables(payload: { biddingType: biddings.biddingType, bidPicName: biddings.bidPicName, supplyPicName: biddings.supplyPicName, + budget: biddings.budget, targetPrice: biddings.targetPrice, awardCount: biddings.awardCount, }) @@ -652,8 +749,11 @@ export async function mapBiddingAwardToTemplateVariables(payload: { const bidding = biddingInfo[0]; // 2. 낙찰된 업체 정보 조회 - const { getAwardedCompanies } = await import('@/lib/bidding/detail/service'); - const awardedCompanies = await getAwardedCompanies(biddingId); + let awardedCompanies = payload.awardedCompanies; + if (!awardedCompanies) { + const { getAwardedCompanies } = await import('@/lib/bidding/detail/service'); + awardedCompanies = await getAwardedCompanies(biddingId); + } // 3. 입찰 대상 자재 정보 조회 const biddingItemsInfo = await db @@ -684,7 +784,7 @@ export async function mapBiddingAwardToTemplateVariables(payload: { const biddingNumber = bidding.biddingNumber || ''; const winnerCount = (bidding.awardCount === 'single' ? 1 : bidding.awardCount === 'multiple' ? 2 : 1).toString(); const contractType = bidding.biddingType || ''; - const budget = bidding.targetPrice ? bidding.targetPrice.toLocaleString() : ''; + const budget = bidding.budget ? bidding.budget.toLocaleString() : ''; const targetPrice = bidding.targetPrice ? bidding.targetPrice.toLocaleString() : ''; const biddingManager = bidding.bidPicName || bidding.supplyPicName || ''; const biddingOverview = bidding.itemName || ''; diff --git a/lib/bidding/list/biddings-page-header.tsx b/lib/bidding/list/biddings-page-header.tsx index 0be2172b..227a917b 100644 --- a/lib/bidding/list/biddings-page-header.tsx +++ b/lib/bidding/list/biddings-page-header.tsx @@ -4,7 +4,11 @@ import { Button } from "@/components/ui/button" import { Plus, FileText, TrendingUp } from "lucide-react" import { useRouter } from "next/navigation" import { InformationButton } from "@/components/information/information-button" -export function BiddingsPageHeader() { +import { useTranslation } from "@/i18n/client" + +export function BiddingsPageHeader(props: {lng: string}) { + const {lng} = props + const {t} = useTranslation(lng, 'menu') const router = useRouter() return ( @@ -12,11 +16,11 @@ export function BiddingsPageHeader() { {/* 좌측: 제목과 설명 */} <div className="space-y-1"> <div className="flex items-center gap-2"> - <h2 className="text-3xl font-bold tracking-tight">입찰 목록 관리</h2> + <h2 className="text-3xl font-bold tracking-tight">{t('menu.procurement.bid_management')}</h2> <InformationButton pagePath="evcp/bid" /> </div> <p className="text-muted-foreground"> - 입찰 공고를 생성하고 진행 상황을 관리할 수 있습니다. + {t('menu.procurement.bid_management_desc')} </p> </div> diff --git a/lib/bidding/list/biddings-table-columns.tsx b/lib/bidding/list/biddings-table-columns.tsx index 62d4dbe7..602bcbb9 100644 --- a/lib/bidding/list/biddings-table-columns.tsx +++ b/lib/bidding/list/biddings-table-columns.tsx @@ -257,21 +257,40 @@ export function getBiddingsColumns({ setRowAction }: GetColumnsProps): ColumnDef id: "submissionPeriod", header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="입찰서제출기간" />, cell: ({ row }) => { + const status = row.original.status + + // 입찰생성 또는 결재진행중 상태일 때는 특별 메시지 표시 + if (status === 'bidding_generated') { + return ( + <div className="text-xs text-orange-600 font-medium"> + 입찰 등록중입니다 + </div> + ) + } + + if (status === 'approval_pending') { + return ( + <div className="text-xs text-blue-600 font-medium"> + 결재 진행중입니다 + </div> + ) + } + const startDate = row.original.submissionStartDate const endDate = row.original.submissionEndDate - + if (!startDate || !endDate) return <span className="text-muted-foreground">-</span> - + const startObj = new Date(startDate) const endObj = new Date(endDate) - // UI 표시용 KST 변환 - const formatKst = (d: Date) => new Date(d.getTime() + 9 * 60 * 60 * 1000).toISOString().slice(0, 16).replace('T', ' ') - + // 입력값 기반: 저장된 UTC 값 그대로 표시 (타임존 가감 없음) + const formatValue = (d: Date) => d.toISOString().slice(0, 16).replace('T', ' ') + return ( <div className="text-xs"> <div> - {formatKst(startObj)} ~ {formatKst(endObj)} + {formatValue(startObj)} ~ {formatValue(endObj)} </div> </div> ) diff --git a/lib/bidding/list/biddings-table-toolbar-actions.tsx b/lib/bidding/list/biddings-table-toolbar-actions.tsx index 33368218..b0007c8c 100644 --- a/lib/bidding/list/biddings-table-toolbar-actions.tsx +++ b/lib/bidding/list/biddings-table-toolbar-actions.tsx @@ -7,7 +7,7 @@ import { } from "lucide-react" import { toast } from "sonner" import { useSession } from "next-auth/react" -import { exportTableToExcel } from "@/lib/export" +import { exportBiddingsToExcel } from "./export-biddings-to-excel" import { Button } from "@/components/ui/button" import { DropdownMenu, @@ -92,6 +92,23 @@ export function BiddingsTableToolbarActions({ table }: BiddingsTableToolbarActio return selectedBiddings.length === 1 && selectedBiddings[0].status === 'bidding_generated' }, [selectedBiddings]) + // Excel 내보내기 핸들러 + const handleExport = React.useCallback(async () => { + try { + setIsExporting(true) + await exportBiddingsToExcel(table, { + filename: "입찰목록", + onlySelected: false, + }) + toast.success("Excel 파일이 다운로드되었습니다.") + } catch (error) { + console.error("Excel export error:", error) + toast.error("Excel 내보내기 중 오류가 발생했습니다.") + } finally { + setIsExporting(false) + } + }, [table]) + return ( <> <div className="flex items-center gap-2"> @@ -100,6 +117,17 @@ export function BiddingsTableToolbarActions({ table }: BiddingsTableToolbarActio // 성공 시 테이블 새로고침 등 추가 작업 // window.location.reload() }} /> + {/* Excel 내보내기 버튼 */} + <Button + variant="outline" + size="sm" + onClick={handleExport} + disabled={isExporting} + className="gap-2" + > + <FileSpreadsheet className="size-4" aria-hidden="true" /> + <span className="hidden sm:inline">{isExporting ? "내보내는 중..." : "Excel 내보내기"}</span> + </Button> {/* 전송하기 (업체선정 완료된 입찰만) */} <Button variant="default" @@ -112,20 +140,16 @@ export function BiddingsTableToolbarActions({ table }: BiddingsTableToolbarActio <span className="hidden sm:inline">전송하기</span> </Button> {/* 삭제 버튼 */} - - <Button - variant="destructive" - size="sm" - onClick={() => setIsDeleteDialogOpen(true)} - disabled={!canDelete} - className="gap-2" - > - <Trash className="size-4" aria-hidden="true" /> - <span className="hidden sm:inline">삭제</span> - </Button> - - - + <Button + variant="destructive" + size="sm" + onClick={() => setIsDeleteDialogOpen(true)} + disabled={!canDelete} + className="gap-2" + > + <Trash className="size-4" aria-hidden="true" /> + <span className="hidden sm:inline">삭제</span> + </Button> </div> {/* 전송 다이얼로그 */} diff --git a/lib/bidding/list/export-biddings-to-excel.ts b/lib/bidding/list/export-biddings-to-excel.ts new file mode 100644 index 00000000..64d98399 --- /dev/null +++ b/lib/bidding/list/export-biddings-to-excel.ts @@ -0,0 +1,209 @@ +import { type Table } from "@tanstack/react-table" +import ExcelJS from "exceljs" +import { BiddingListItem } from "@/db/schema" +import { + biddingStatusLabels, + contractTypeLabels, + biddingTypeLabels, +} from "@/db/schema" +import { formatDate } from "@/lib/utils" + +// BiddingListItem 확장 타입 (manager 정보 포함) +type BiddingListItemWithManagerCode = BiddingListItem & { + bidPicName?: string | null + supplyPicName?: string | null +} + +/** + * 입찰 목록을 Excel로 내보내기 + * - 계약구분, 진행상태, 입찰유형은 라벨(명칭)로 변환 + * - 입찰서 제출기간은 submissionStartDate, submissionEndDate 기준 + * - 등록일시는 년, 월, 일 형식 + */ +export async function exportBiddingsToExcel( + table: Table<BiddingListItemWithManagerCode>, + { + filename = "입찰목록", + onlySelected = false, + }: { + filename?: string + onlySelected?: boolean + } = {} +): Promise<void> { + // 테이블에서 실제 사용 중인 leaf columns 가져오기 + const allColumns = table.getAllLeafColumns() + + // select, actions 컬럼 제외 + const columns = allColumns.filter( + (col) => !["select", "actions"].includes(col.id) + ) + + // 헤더 행 생성 (excelHeader 사용) + const headerRow = columns.map((col) => { + const excelHeader = (col.columnDef.meta as any)?.excelHeader + return typeof excelHeader === "string" ? excelHeader : col.id + }) + + // 데이터 행 생성 + const rowModel = onlySelected + ? table.getFilteredSelectedRowModel() + : table.getRowModel() + + const dataRows = rowModel.rows.map((row) => { + const original = row.original + return columns.map((col) => { + const colId = col.id + let value: any + + // 특별 처리 필요한 컬럼들 + switch (colId) { + case "contractType": + // 계약구분: 라벨로 변환 + value = contractTypeLabels[original.contractType as keyof typeof contractTypeLabels] || original.contractType + break + + case "status": + // 진행상태: 라벨로 변환 + value = biddingStatusLabels[original.status as keyof typeof biddingStatusLabels] || original.status + break + + case "biddingType": + // 입찰유형: 라벨로 변환 + value = biddingTypeLabels[original.biddingType as keyof typeof biddingTypeLabels] || original.biddingType + break + + case "submissionPeriod": + // 입찰서 제출기간: submissionStartDate, submissionEndDate 기준 + const startDate = original.submissionStartDate + const endDate = original.submissionEndDate + + if (!startDate || !endDate) { + value = "-" + } else { + const startObj = new Date(startDate) + const endObj = new Date(endDate) + + // 입력값 기반: 저장된 UTC 값을 그대로 표시 (타임존 가감 없음) + const formatValue = (d: Date) => d.toISOString().slice(0, 16).replace('T', ' ') + + value = `${formatValue(startObj)} ~ ${formatValue(endObj)}` + } + break + + case "updatedAt": + // 등록일시: 년, 월, 일 형식만 + if (original.updatedAt) { + value = formatDate(original.updatedAt, "KR") + } else { + value = "-" + } + break + + case "biddingRegistrationDate": + // 입찰등록일: 년, 월, 일 형식만 + if (original.biddingRegistrationDate) { + value = formatDate(original.biddingRegistrationDate, "KR") + } else { + value = "-" + } + break + + case "projectName": + // 프로젝트: 코드와 이름 조합 + const code = original.projectCode + const name = original.projectName + value = code && name ? `${code} (${name})` : (code || name || "-") + break + + case "hasSpecificationMeeting": + // 사양설명회: Yes/No + value = original.hasSpecificationMeeting ? "Yes" : "No" + break + + default: + // 기본값: row.getValue 사용 + value = row.getValue(colId) + + // null/undefined 처리 + if (value == null) { + value = "" + } + + // 객체인 경우 JSON 문자열로 변환 + if (typeof value === "object") { + value = JSON.stringify(value) + } + break + } + + return value + }) + }) + + // 최종 sheetData + const sheetData = [headerRow, ...dataRows] + + // ExcelJS로 파일 생성 및 다운로드 + await createAndDownloadExcel(sheetData, columns.length, filename) +} + +/** + * Excel 파일 생성 및 다운로드 + */ +async function createAndDownloadExcel( + sheetData: any[][], + columnCount: number, + filename: string +): Promise<void> { + // ExcelJS 워크북/시트 생성 + const workbook = new ExcelJS.Workbook() + const worksheet = workbook.addWorksheet("Sheet1") + + // 칼럼별 최대 길이 추적 + const maxColumnLengths = Array(columnCount).fill(0) + sheetData.forEach((row) => { + row.forEach((cellValue, colIdx) => { + const cellText = cellValue?.toString() ?? "" + if (cellText.length > maxColumnLengths[colIdx]) { + maxColumnLengths[colIdx] = cellText.length + } + }) + }) + + // 시트에 데이터 추가 + 헤더 스타일 + sheetData.forEach((arr, idx) => { + const row = worksheet.addRow(arr) + + // 헤더 스타일 적용 (첫 번째 행) + if (idx === 0) { + row.font = { bold: true } + row.alignment = { horizontal: "center" } + row.eachCell((cell) => { + cell.fill = { + type: "pattern", + pattern: "solid", + fgColor: { argb: "FFCCCCCC" }, + } + }) + } + }) + + // 칼럼 너비 자동 조정 + maxColumnLengths.forEach((len, idx) => { + // 최소 너비 10, +2 여백 + worksheet.getColumn(idx + 1).width = Math.max(len + 2, 10) + }) + + // 최종 파일 다운로드 + const buffer = await workbook.xlsx.writeBuffer() + const blob = new Blob([buffer], { + type: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", + }) + const url = URL.createObjectURL(blob) + const link = document.createElement("a") + link.href = url + link.download = `${filename}.xlsx` + link.click() + URL.revokeObjectURL(url) +} + diff --git a/lib/bidding/manage/export-bidding-items-to-excel.ts b/lib/bidding/manage/export-bidding-items-to-excel.ts new file mode 100644 index 00000000..814648a7 --- /dev/null +++ b/lib/bidding/manage/export-bidding-items-to-excel.ts @@ -0,0 +1,161 @@ +import ExcelJS from "exceljs" +import { PRItemInfo } from "@/components/bidding/manage/bidding-items-editor" +import { getProjectCodesByIds } from "./project-utils" + +/** + * 입찰품목 목록을 Excel로 내보내기 + */ +export async function exportBiddingItemsToExcel( + items: PRItemInfo[], + { + filename = "입찰품목목록", + }: { + filename?: string + } = {} +): Promise<void> { + // 프로젝트 ID 목록 수집 + const projectIds = items + .map((item) => item.projectId) + .filter((id): id is number => id != null && id > 0) + + // 프로젝트 코드 맵 조회 + const projectCodeMap = await getProjectCodesByIds(projectIds) + + // 헤더 정의 + const headers = [ + "프로젝트코드", + "프로젝트명", + "자재그룹코드", + "자재그룹명", + "자재코드", + "자재명", + "수량", + "수량단위", + "중량", + "중량단위", + "납품요청일", + "가격단위", + "구매단위", + "자재순중량", + "내정단가", + "내정금액", + "내정통화", + "예산금액", + "예산통화", + "실적금액", + "실적통화", + "WBS코드", + "WBS명", + "코스트센터코드", + "코스트센터명", + "GL계정코드", + "GL계정명", + "PR번호", + ] + + // 데이터 행 생성 + const dataRows = items.map((item) => { + // 프로젝트 코드 조회 + const projectCode = item.projectId + ? projectCodeMap.get(item.projectId) || "" + : "" + + return [ + projectCode, + item.projectInfo || "", + item.materialGroupNumber || "", + item.materialGroupInfo || "", + item.materialNumber || "", + item.materialInfo || "", + item.quantity || "", + item.quantityUnit || "", + item.totalWeight || "", + item.weightUnit || "", + item.requestedDeliveryDate || "", + item.priceUnit || "", + item.purchaseUnit || "", + item.materialWeight || "", + item.targetUnitPrice || "", + item.targetAmount || "", + item.targetCurrency || "KRW", + item.budgetAmount || "", + item.budgetCurrency || "KRW", + item.actualAmount || "", + item.actualCurrency || "KRW", + item.wbsCode || "", + item.wbsName || "", + item.costCenterCode || "", + item.costCenterName || "", + item.glAccountCode || "", + item.glAccountName || "", + item.prNumber || "", + ] + }) + + // 최종 sheetData + const sheetData = [headers, ...dataRows] + + // ExcelJS로 파일 생성 및 다운로드 + await createAndDownloadExcel(sheetData, headers.length, filename) +} + +/** + * Excel 파일 생성 및 다운로드 + */ +async function createAndDownloadExcel( + sheetData: any[][], + columnCount: number, + filename: string +): Promise<void> { + // ExcelJS 워크북/시트 생성 + const workbook = new ExcelJS.Workbook() + const worksheet = workbook.addWorksheet("Sheet1") + + // 칼럼별 최대 길이 추적 + const maxColumnLengths = Array(columnCount).fill(0) + sheetData.forEach((row) => { + row.forEach((cellValue, colIdx) => { + const cellText = cellValue?.toString() ?? "" + if (cellText.length > maxColumnLengths[colIdx]) { + maxColumnLengths[colIdx] = cellText.length + } + }) + }) + + // 시트에 데이터 추가 + 헤더 스타일 + sheetData.forEach((arr, idx) => { + const row = worksheet.addRow(arr) + + // 헤더 스타일 적용 (첫 번째 행) + if (idx === 0) { + row.font = { bold: true } + row.alignment = { horizontal: "center" } + row.eachCell((cell) => { + cell.fill = { + type: "pattern", + pattern: "solid", + fgColor: { argb: "FFCCCCCC" }, + } + }) + } + }) + + // 칼럼 너비 자동 조정 + maxColumnLengths.forEach((len, idx) => { + // 최소 너비 10, +2 여백 + worksheet.getColumn(idx + 1).width = Math.max(len + 2, 10) + }) + + // 최종 파일 다운로드 + const buffer = await workbook.xlsx.writeBuffer() + const blob = new Blob([buffer], { + type: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", + }) + const url = URL.createObjectURL(blob) + const link = document.createElement("a") + link.href = url + link.download = `${filename}.xlsx` + link.click() + URL.revokeObjectURL(url) +} + diff --git a/lib/bidding/manage/import-bidding-items-from-excel.ts b/lib/bidding/manage/import-bidding-items-from-excel.ts new file mode 100644 index 00000000..fe5b17a9 --- /dev/null +++ b/lib/bidding/manage/import-bidding-items-from-excel.ts @@ -0,0 +1,273 @@ +import ExcelJS from "exceljs" +import { PRItemInfo } from "@/components/bidding/manage/bidding-items-editor" +import { getProjectIdByCodeAndName } from "./project-utils" +import { decryptWithServerAction } from "@/components/drm/drmUtils" + +export interface ImportBiddingItemsResult { + success: boolean + items: PRItemInfo[] + errors: string[] +} + +/** + * Excel 파일에서 입찰품목 데이터 파싱 + */ +export async function importBiddingItemsFromExcel( + file: File +): Promise<ImportBiddingItemsResult> { + const errors: string[] = [] + const items: PRItemInfo[] = [] + + try { + const workbook = new ExcelJS.Workbook() + // DRM 해제 후 ArrayBuffer 획득 (DRM 서버 미연결 시 원본 반환) + const arrayBuffer = await decryptWithServerAction(file) + await workbook.xlsx.load(arrayBuffer) + + const worksheet = workbook.worksheets[0] + if (!worksheet) { + return { + success: false, + items: [], + errors: ["Excel 파일에 시트가 없습니다."], + } + } + + // 헤더 행 읽기 (첫 번째 행) + const headerRow = worksheet.getRow(1) + const headerValues = headerRow.values as ExcelJS.CellValue[] + + // 헤더 매핑 생성 + const headerMap: Record<string, number> = {} + const expectedHeaders = [ + "프로젝트코드", + "프로젝트명", + "자재그룹코드", + "자재그룹명", + "자재코드", + "자재명", + "수량", + "수량단위", + "중량", + "중량단위", + "납품요청일", + "가격단위", + "구매단위", + "자재순중량", + "내정단가", + "내정금액", + "내정통화", + "예산금액", + "예산통화", + "실적금액", + "실적통화", + "WBS코드", + "WBS명", + "코스트센터코드", + "코스트센터명", + "GL계정코드", + "GL계정명", + "PR번호", + ] + + // 헤더 인덱스 매핑 + for (let i = 1; i < headerValues.length; i++) { + const headerValue = String(headerValues[i] || "").trim() + if (headerValue && expectedHeaders.includes(headerValue)) { + headerMap[headerValue] = i + } + } + + // 필수 헤더 확인 + const requiredHeaders = ["자재그룹코드", "자재그룹명"] + const missingHeaders = requiredHeaders.filter( + (h) => !headerMap[h] + ) + if (missingHeaders.length > 0) { + errors.push( + `필수 컬럼이 없습니다: ${missingHeaders.join(", ")}` + ) + } + + // 데이터 행 읽기 (2번째 행부터) + for (let rowIndex = 2; rowIndex <= worksheet.rowCount; rowIndex++) { + const row = worksheet.getRow(rowIndex) + const rowValues = row.values as ExcelJS.CellValue[] + + // 빈 행 건너뛰기 + if (rowValues.every((val) => !val || String(val).trim() === "")) { + continue + } + + // 셀 값 추출 헬퍼 + const getCellValue = (headerName: string): string => { + const colIndex = headerMap[headerName] + if (!colIndex) return "" + const value = rowValues[colIndex] + if (value == null) return "" + + // ExcelJS 객체 처리 + if (typeof value === "object" && "text" in value) { + return String((value as any).text || "") + } + + // 날짜 처리 + if (value instanceof Date) { + return value.toISOString().split("T")[0] + } + + return String(value).trim() + } + + // 필수값 검증 + const materialGroupNumber = getCellValue("자재그룹코드") + const materialGroupInfo = getCellValue("자재그룹명") + + if (!materialGroupNumber || !materialGroupInfo) { + errors.push( + `${rowIndex}번 행: 자재그룹코드와 자재그룹명은 필수입니다.` + ) + continue + } + + // 수량 또는 중량 검증 + const quantity = getCellValue("수량") + const totalWeight = getCellValue("중량") + const quantityUnit = getCellValue("수량단위") + const weightUnit = getCellValue("중량단위") + + if (!quantity && !totalWeight) { + errors.push( + `${rowIndex}번 행: 수량 또는 중량 중 하나는 필수입니다.` + ) + continue + } + + if (quantity && !quantityUnit) { + errors.push( + `${rowIndex}번 행: 수량이 있으면 수량단위가 필수입니다.` + ) + continue + } + + if (totalWeight && !weightUnit) { + errors.push( + `${rowIndex}번 행: 중량이 있으면 중량단위가 필수입니다.` + ) + continue + } + + // 납품요청일 검증 + const requestedDeliveryDate = getCellValue("납품요청일") + if (!requestedDeliveryDate) { + errors.push( + `${rowIndex}번 행: 납품요청일은 필수입니다.` + ) + continue + } + + // 날짜 형식 검증 + const dateRegex = /^\d{4}-\d{2}-\d{2}$/ + if (requestedDeliveryDate && !dateRegex.test(requestedDeliveryDate)) { + errors.push( + `${rowIndex}번 행: 납품요청일 형식이 올바르지 않습니다. (YYYY-MM-DD 형식)` + ) + continue + } + + // 내정단가 검증 (필수) + const targetUnitPrice = getCellValue("내정단가") + if (!targetUnitPrice || parseFloat(targetUnitPrice.replace(/,/g, "")) <= 0) { + errors.push( + `${rowIndex}번 행: 내정단가는 필수이며 0보다 커야 합니다.` + ) + continue + } + + // 숫자 값 정리 (콤마 제거) + const cleanNumber = (value: string): string => { + return value.replace(/,/g, "").trim() + } + + // 프로젝트 ID 조회 (프로젝트코드와 프로젝트명으로) + const projectCode = getCellValue("프로젝트코드") + const projectName = getCellValue("프로젝트명") + let projectId: number | null = null + + if (projectCode && projectName) { + projectId = await getProjectIdByCodeAndName(projectCode, projectName) + if (!projectId) { + errors.push( + `${rowIndex}번 행: 프로젝트코드 "${projectCode}"와 프로젝트명 "${projectName}"에 해당하는 프로젝트를 찾을 수 없습니다.` + ) + // 프로젝트를 찾지 못해도 계속 진행 (경고만 표시) + } + } + + // PRItemInfo 객체 생성 + const item: PRItemInfo = { + id: -(rowIndex - 1), // 임시 ID (음수) + prNumber: getCellValue("PR번호") || null, + projectId: projectId, + projectInfo: projectName || null, + shi: null, + quantity: quantity ? cleanNumber(quantity) : null, + quantityUnit: quantityUnit || null, + totalWeight: totalWeight ? cleanNumber(totalWeight) : null, + weightUnit: weightUnit || null, + materialDescription: null, + hasSpecDocument: false, + requestedDeliveryDate: requestedDeliveryDate || null, + isRepresentative: false, + annualUnitPrice: null, + currency: "KRW", + materialGroupNumber: materialGroupNumber || null, + materialGroupInfo: materialGroupInfo || null, + materialNumber: getCellValue("자재코드") || null, + materialInfo: getCellValue("자재명") || null, + priceUnit: getCellValue("가격단위") || "1", + purchaseUnit: getCellValue("구매단위") || "EA", + materialWeight: getCellValue("자재순중량") || null, + wbsCode: getCellValue("WBS코드") || null, + wbsName: getCellValue("WBS명") || null, + costCenterCode: getCellValue("코스트센터코드") || null, + costCenterName: getCellValue("코스트센터명") || null, + glAccountCode: getCellValue("GL계정코드") || null, + glAccountName: getCellValue("GL계정명") || null, + targetUnitPrice: cleanNumber(targetUnitPrice) || null, + targetAmount: getCellValue("내정금액") + ? cleanNumber(getCellValue("내정금액")) + : null, + targetCurrency: getCellValue("내정통화") || "KRW", + budgetAmount: getCellValue("예산금액") + ? cleanNumber(getCellValue("예산금액")) + : null, + budgetCurrency: getCellValue("예산통화") || "KRW", + actualAmount: getCellValue("실적금액") + ? cleanNumber(getCellValue("실적금액")) + : null, + actualCurrency: getCellValue("실적통화") || "KRW", + } + + items.push(item) + } + + return { + success: errors.length === 0, + items, + errors, + } + } catch (error) { + console.error("Excel import error:", error) + return { + success: false, + items: [], + errors: [ + error instanceof Error + ? error.message + : "Excel 파일 파싱 중 오류가 발생했습니다.", + ], + } + } +} + diff --git a/lib/bidding/manage/project-utils.ts b/lib/bidding/manage/project-utils.ts new file mode 100644 index 00000000..92744695 --- /dev/null +++ b/lib/bidding/manage/project-utils.ts @@ -0,0 +1,87 @@ +'use server' + +import db from '@/db/db' +import { projects } from '@/db/schema' +import { eq, and, inArray } from 'drizzle-orm' + +/** + * 프로젝트 ID로 프로젝트 코드 조회 + */ +export async function getProjectCodeById(projectId: number): Promise<string | null> { + try { + const result = await db + .select({ code: projects.code }) + .from(projects) + .where(eq(projects.id, projectId)) + .limit(1) + + return result[0]?.code || null + } catch (error) { + console.error('Failed to get project code by id:', error) + return null + } +} + +/** + * 프로젝트 코드와 이름으로 프로젝트 ID 조회 + */ +export async function getProjectIdByCodeAndName( + projectCode: string, + projectName: string +): Promise<number | null> { + try { + if (!projectCode || !projectName) { + return null + } + + const result = await db + .select({ id: projects.id }) + .from(projects) + .where( + and( + eq(projects.code, projectCode.trim()), + eq(projects.name, projectName.trim()) + ) + ) + .limit(1) + + return result[0]?.id || null + } catch (error) { + console.error('Failed to get project id by code and name:', error) + return null + } +} + +/** + * 여러 프로젝트 ID로 프로젝트 코드 맵 조회 (성능 최적화) + */ +export async function getProjectCodesByIds( + projectIds: number[] +): Promise<Map<number, string>> { + try { + if (projectIds.length === 0) { + return new Map() + } + + const uniqueIds = [...new Set(projectIds.filter(id => id != null))] + if (uniqueIds.length === 0) { + return new Map() + } + + const result = await db + .select({ id: projects.id, code: projects.code }) + .from(projects) + .where(inArray(projects.id, uniqueIds)) + + const map = new Map<number, string>() + result.forEach((project) => { + map.set(project.id, project.code) + }) + + return map + } catch (error) { + console.error('Failed to get project codes by ids:', error) + return new Map() + } +} + diff --git a/lib/bidding/pre-quote/service.ts b/lib/bidding/pre-quote/service.ts index 08cb0e2c..6fef228c 100644 --- a/lib/bidding/pre-quote/service.ts +++ b/lib/bidding/pre-quote/service.ts @@ -49,16 +49,6 @@ interface UpdateBiddingCompanyInput { isAttendingMeeting?: boolean
}
-interface PrItemQuotation {
- prItemId: number
- bidUnitPrice: number
- bidAmount: number
- proposedDeliveryDate?: string
- technicalSpecification?: string
-}
-
-
-
// 사전견적용 업체 추가 - biddingCompanies와 company_condition_responses 레코드 생성
export async function createBiddingCompany(input: CreateBiddingCompanyInput) {
try {
@@ -201,16 +191,6 @@ export async function deleteBiddingCompany(id: number) { }
-// 선택된 업체들에게 사전견적 초대 발송
-interface CompanyWithContacts {
- id: number
- companyId: number
- companyName: string
- selectedMainEmail: string
- additionalEmails: string[]
-}
-
-
// PR 아이템 조회 (입찰에 포함된 품목들)
export async function getPrItemsForBidding(biddingId: number, companyId?: number) {
try {
@@ -253,12 +233,11 @@ export async function getPrItemsForBidding(biddingId: number, companyId?: number selectFields.bidAmount = companyPrItemBids.bidAmount
selectFields.proposedDeliveryDate = companyPrItemBids.proposedDeliveryDate
selectFields.technicalSpecification = companyPrItemBids.technicalSpecification
- }
-
- let query = db.select(selectFields).from(prItemsForBidding)
+ selectFields.currency = companyPrItemBids.currency
- if (companyId) {
- query = query
+ return await db
+ .select(selectFields)
+ .from(prItemsForBidding)
.leftJoin(biddingCompanies, and(
eq(biddingCompanies.biddingId, biddingId),
eq(biddingCompanies.companyId, companyId)
@@ -266,13 +245,16 @@ export async function getPrItemsForBidding(biddingId: number, companyId?: number .leftJoin(companyPrItemBids, and(
eq(companyPrItemBids.prItemId, prItemsForBidding.id),
eq(companyPrItemBids.biddingCompanyId, biddingCompanies.id)
- )) as any
+ ))
+ .where(eq(prItemsForBidding.biddingId, biddingId))
+ .orderBy(prItemsForBidding.id)
+ } else {
+ return await db
+ .select(selectFields)
+ .from(prItemsForBidding)
+ .where(eq(prItemsForBidding.biddingId, biddingId))
+ .orderBy(prItemsForBidding.id)
}
-
- query = query.where(eq(prItemsForBidding.biddingId, biddingId)).orderBy(prItemsForBidding.id) as any
-
- const prItems = await query
- return prItems
} catch (error) {
console.error('Failed to get PR items for bidding:', error)
return []
@@ -877,8 +859,8 @@ export async function getSelectedVendorsForBidding(biddingId: number) { interface CreatePreQuoteRfqInput {
rfqType: string;
rfqTitle: string;
- dueDate: Date;
- picUserId: number;
+ dueDate?: Date;
+ picUserId: number | string | undefined;
projectId?: number;
remark?: string;
biddingNumber?: string;
@@ -893,6 +875,8 @@ interface CreatePreQuoteRfqInput { remark?: string;
materialCode?: string;
materialName?: string;
+ totalWeight?: number | string | null; // 중량 추가
+ weightUnit?: string | null; // 중량단위 추가
}>;
biddingConditions?: {
paymentTerms?: string | null
@@ -994,6 +978,10 @@ export async function createPreQuoteRfqAction(input: CreatePreQuoteRfqInput) { quantity: item.quantity, // 수량
uom: item.uom, // 단위
+ // 중량 정보
+ grossWeight: item.totalWeight ? (typeof item.totalWeight === 'string' ? parseFloat(item.totalWeight) : item.totalWeight) : null,
+ gwUom: item.weightUnit || null,
+
majorYn: index === 0, // 첫 번째 아이템을 주요 아이템으로 설정
remark: item.remark || null, // 비고
}));
diff --git a/lib/bidding/receive/biddings-receive-columns.tsx b/lib/bidding/receive/biddings-receive-columns.tsx index 9650574a..6847d9d5 100644 --- a/lib/bidding/receive/biddings-receive-columns.tsx +++ b/lib/bidding/receive/biddings-receive-columns.tsx @@ -1,404 +1,404 @@ -"use client"
-
-import * as React from "react"
-import { type ColumnDef } from "@tanstack/react-table"
-import { Badge } from "@/components/ui/badge"
-import { Button } from "@/components/ui/button"
-import { Checkbox } from "@/components/ui/checkbox"
-import {
- Eye, Calendar, Users, CheckCircle, XCircle, Clock, AlertTriangle
-} from "lucide-react"
-import {
- Tooltip,
- TooltipContent,
- TooltipProvider,
- TooltipTrigger,
-} from "@/components/ui/tooltip"
-import {
- DropdownMenu,
- DropdownMenuContent,
- DropdownMenuItem,
- DropdownMenuSeparator,
- DropdownMenuTrigger,
-} from "@/components/ui/dropdown-menu"
-import { DataTableColumnHeaderSimple } from "@/components/data-table/data-table-column-simple-header"
-import {
- biddingStatusLabels,
- contractTypeLabels,
-} from "@/db/schema"
-import { formatDate } from "@/lib/utils"
-import { DataTableRowAction } from "@/types/table"
-
-type BiddingReceiveItem = {
- id: number
- biddingNumber: string
- originalBiddingNumber: string | null
- title: string
- status: string
- contractType: string
- prNumber: string | null
- submissionStartDate: Date | null
- submissionEndDate: Date | null
- bidPicName: string | null
- supplyPicName: string | null
- createdBy: string | null
- createdAt: Date | null
- updatedAt: Date | null
-
- // 참여 현황
- participantExpected: number
- participantParticipated: number
- participantDeclined: number
- participantPending: number
-
- // 개찰 정보
- openedAt: Date | null
- openedBy: string | null
-}
-
-interface GetColumnsProps {
- setRowAction: React.Dispatch<React.SetStateAction<DataTableRowAction<BiddingReceiveItem> | null>>
- onParticipantClick?: (biddingId: number, participantType: 'expected' | 'participated' | 'declined' | 'pending') => void
-}
-
-// 상태별 배지 색상
-const getStatusBadgeVariant = (status: string) => {
- switch (status) {
- case 'received_quotation':
- return 'secondary'
- case 'bidding_opened':
- return 'default'
- case 'bidding_closed':
- return 'outline'
- default:
- return 'outline'
- }
-}
-
-// 금액 포맷팅
-const formatCurrency = (amount: string | number | null, currency = 'KRW') => {
- if (!amount) return '-'
-
- const numAmount = typeof amount === 'string' ? parseFloat(amount) : amount
- if (isNaN(numAmount)) return '-'
-
- return new Intl.NumberFormat('ko-KR', {
- style: 'currency',
- currency: currency,
- minimumFractionDigits: 0,
- maximumFractionDigits: 0,
- }).format(numAmount)
-}
-
-export function getBiddingsReceiveColumns({ setRowAction, onParticipantClick }: GetColumnsProps): ColumnDef<BiddingReceiveItem>[] {
-
- return [
- // ░░░ 선택 ░░░
- {
- id: "select",
- header: "",
- cell: ({ row }) => (
- <Checkbox
- checked={row.getIsSelected()}
- onCheckedChange={(value) => {
- // single select 모드에서는 다른 행들의 선택을 해제
- row.toggleSelected(!!value)
- }}
- aria-label="행 선택"
- />
- ),
- size: 50,
- enableSorting: false,
- enableHiding: false,
- },
-
- // ░░░ 입찰번호 ░░░
- {
- accessorKey: "biddingNumber",
- header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="입찰번호" />,
- cell: ({ row }) => (
- <div className="font-mono text-sm">
- {row.original.biddingNumber}
- </div>
- ),
- size: 120,
- meta: { excelHeader: "입찰번호" },
- },
-
- // ░░░ 입찰명 ░░░
- {
- accessorKey: "title",
- header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="입찰명" />,
- cell: ({ row }) => (
- <div className="truncate max-w-[200px]" title={row.original.title}>
- {/* <Button
- variant="link"
- className="p-0 h-auto text-left justify-start font-bold underline"
- onClick={() => setRowAction({ row, type: "view" })}
- >
- <div className="whitespace-pre-line">
- {row.original.title}
- </div>
- </Button> */}
- {row.original.title}
- </div>
- ),
- size: 200,
- meta: { excelHeader: "입찰명" },
- },
-
- // ░░░ 원입찰번호 ░░░
- {
- accessorKey: "originalBiddingNumber",
- header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="원입찰번호" />,
- cell: ({ row }) => (
- <div className="font-mono text-sm">
- {row.original.originalBiddingNumber || '-'}
- </div>
- ),
- size: 120,
- meta: { excelHeader: "원입찰번호" },
- },
-
- // ░░░ 진행상태 ░░░
- {
- accessorKey: "status",
- header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="진행상태" />,
- cell: ({ row }) => (
- <Badge variant={getStatusBadgeVariant(row.original.status)}>
- {biddingStatusLabels[row.original.status]}
- </Badge>
- ),
- size: 120,
- meta: { excelHeader: "진행상태" },
- },
-
- // ░░░ 계약구분 ░░░
- {
- accessorKey: "contractType",
- header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="계약구분" />,
- cell: ({ row }) => (
- <Badge variant="outline">
- {contractTypeLabels[row.original.contractType]}
- </Badge>
- ),
- size: 100,
- meta: { excelHeader: "계약구분" },
- },
-
- // ░░░ 입찰서제출기간 ░░░
- {
- id: "submissionPeriod",
- header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="입찰서제출기간" />,
- cell: ({ row }) => {
- const startDate = row.original.submissionStartDate
- const endDate = row.original.submissionEndDate
-
- if (!startDate || !endDate) return <span className="text-muted-foreground">-</span>
-
- const startObj = new Date(startDate)
- const endObj = new Date(endDate)
-
- // UI 표시용 KST 변환
- const formatKst = (d: Date) => new Date(d.getTime() + 9 * 60 * 60 * 1000).toISOString().slice(0, 16).replace('T', ' ')
-
- return (
- <div className="text-xs">
- <div>
- {formatKst(startObj)} ~ {formatKst(endObj)}
- </div>
- </div>
- )
- },
- size: 140,
- meta: { excelHeader: "입찰서제출기간" },
- },
-
- // ░░░ P/R번호 ░░░
- {
- accessorKey: "prNumber",
- header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="P/R번호" />,
- cell: ({ row }) => (
- <span className="font-mono text-sm">{row.original.prNumber || '-'}</span>
- ),
- size: 100,
- meta: { excelHeader: "P/R번호" },
- },
-
- // ░░░ 입찰담당자 ░░░
- {
- accessorKey: "bidPicName",
- header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="입찰담당자" />,
- cell: ({ row }) => {
- const bidPic = row.original.bidPicName
- const supplyPic = row.original.supplyPicName
-
- const displayName = bidPic || supplyPic || "-"
- return <span className="text-sm">{displayName}</span>
- },
- size: 100,
- meta: { excelHeader: "입찰담당자" },
- },
-
- // ░░░ 참여예정협력사 ░░░
- {
- id: "participantExpected",
- header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="참여예정협력사" />,
- cell: ({ row }) => (
- <Button
- variant="ghost"
- size="sm"
- className="h-auto p-1 hover:bg-blue-50"
- onClick={() => onParticipantClick?.(row.original.id, 'expected')}
- disabled={row.original.participantExpected === 0}
- >
- <div className="flex items-center gap-1">
- <Users className="h-4 w-4 text-blue-500" />
- <span className="text-sm font-medium">{row.original.participantExpected}</span>
- </div>
- </Button>
- ),
- size: 100,
- meta: { excelHeader: "참여예정협력사" },
- },
-
- // ░░░ 참여협력사 ░░░
- {
- id: "participantParticipated",
- header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="참여협력사" />,
- cell: ({ row }) => (
- <Button
- variant="ghost"
- size="sm"
- className="h-auto p-1 hover:bg-green-50"
- onClick={() => onParticipantClick?.(row.original.id, 'participated')}
- disabled={row.original.participantParticipated === 0}
- >
- <div className="flex items-center gap-1">
- <CheckCircle className="h-4 w-4 text-green-500" />
- <span className="text-sm font-medium">{row.original.participantParticipated}</span>
- </div>
- </Button>
- ),
- size: 100,
- meta: { excelHeader: "참여협력사" },
- },
-
- // ░░░ 포기협력사 ░░░
- {
- id: "participantDeclined",
- header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="포기협력사" />,
- cell: ({ row }) => (
- <Button
- variant="ghost"
- size="sm"
- className="h-auto p-1 hover:bg-red-50"
- onClick={() => onParticipantClick?.(row.original.id, 'declined')}
- disabled={row.original.participantDeclined === 0}
- >
- <div className="flex items-center gap-1">
- <XCircle className="h-4 w-4 text-red-500" />
- <span className="text-sm font-medium">{row.original.participantDeclined}</span>
- </div>
- </Button>
- ),
- size: 100,
- meta: { excelHeader: "포기협력사" },
- },
-
- // ░░░ 미제출협력사 ░░░
- {
- id: "participantPending",
- header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="미제출협력사" />,
- cell: ({ row }) => (
- <Button
- variant="ghost"
- size="sm"
- className="h-auto p-1 hover:bg-yellow-50"
- onClick={() => onParticipantClick?.(row.original.id, 'pending')}
- disabled={row.original.participantPending === 0}
- >
- <div className="flex items-center gap-1">
- <Clock className="h-4 w-4 text-yellow-500" />
- <span className="text-sm font-medium">{row.original.participantPending}</span>
- </div>
- </Button>
- ),
- size: 100,
- meta: { excelHeader: "미제출협력사" },
- },
-
- // ░░░ 개찰자명 ░░░
- {
- id: "openedBy",
- header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="개찰자명" />,
- cell: ({ row }) => {
- const openedBy = row.original.openedBy
- return <span className="text-sm">{openedBy || '-'}</span>
- },
- size: 100,
- meta: { excelHeader: "개찰자명" },
- },
-
- // ░░░ 개찰일 ░░░
- {
- id: "openedAt",
- header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="개찰일" />,
- cell: ({ row }) => {
- const openedAt = row.original.openedAt
- return <span className="text-sm">{openedAt ? formatDate(openedAt, "KR") : '-'}</span>
- },
- size: 100,
- meta: { excelHeader: "개찰일" },
- },
-
- // ░░░ 등록자 ░░░
- {
- accessorKey: "createdBy",
- header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="등록자" />,
- cell: ({ row }) => (
- <span className="text-sm">{row.original.createdBy || '-'}</span>
- ),
- size: 100,
- meta: { excelHeader: "등록자" },
- },
-
- // ░░░ 등록일시 ░░░
- {
- accessorKey: "createdAt",
- header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="등록일시" />,
- cell: ({ row }) => (
- <span className="text-sm">{row.original.createdAt ? formatDate(row.original.createdAt, "KR") : '-'}</span>
- ),
- size: 100,
- meta: { excelHeader: "등록일시" },
- },
-
- // ═══════════════════════════════════════════════════════════════
- // 액션
- // ═══════════════════════════════════════════════════════════════
- // {
- // id: "actions",
- // header: "액션",
- // cell: ({ row }) => (
- // <DropdownMenu>
- // <DropdownMenuTrigger asChild>
- // <Button variant="ghost" className="h-8 w-8 p-0">
- // <span className="sr-only">메뉴 열기</span>
- // <AlertTriangle className="h-4 w-4" />
- // </Button>
- // </DropdownMenuTrigger>
- // <DropdownMenuContent align="end">
- // <DropdownMenuItem onClick={() => setRowAction({ row, type: "view" })}>
- // <Eye className="mr-2 h-4 w-4" />
- // 상세보기
- // </DropdownMenuItem>
- // </DropdownMenuContent>
- // </DropdownMenu>
- // ),
- // size: 50,
- // enableSorting: false,
- // enableHiding: false,
- // },
- ]
-}
+"use client" + +import * as React from "react" +import { type ColumnDef } from "@tanstack/react-table" +import { Badge } from "@/components/ui/badge" +import { Button } from "@/components/ui/button" +import { Checkbox } from "@/components/ui/checkbox" +import { + Eye, Calendar, Users, CheckCircle, XCircle, Clock, AlertTriangle +} from "lucide-react" +import { + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger, +} from "@/components/ui/tooltip" +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu" +import { DataTableColumnHeaderSimple } from "@/components/data-table/data-table-column-simple-header" +import { + biddingStatusLabels, + contractTypeLabels, +} from "@/db/schema" +import { formatDate } from "@/lib/utils" +import { DataTableRowAction } from "@/types/table" + +type BiddingReceiveItem = { + id: number + biddingNumber: string + originalBiddingNumber: string | null + title: string + status: string + contractType: string + prNumber: string | null + submissionStartDate: Date | null + submissionEndDate: Date | null + bidPicName: string | null + supplyPicName: string | null + createdBy: string | null + createdAt: Date | null + updatedAt: Date | null + + // 참여 현황 + participantExpected: number + participantParticipated: number + participantDeclined: number + participantPending: number + + // 개찰 정보 + openedAt: Date | null + openedBy: string | null +} + +interface GetColumnsProps { + setRowAction: React.Dispatch<React.SetStateAction<DataTableRowAction<BiddingReceiveItem> | null>> + onParticipantClick?: (biddingId: number, participantType: 'expected' | 'participated' | 'declined' | 'pending') => void +} + +// 상태별 배지 색상 +const getStatusBadgeVariant = (status: string) => { + switch (status) { + case 'received_quotation': + return 'secondary' + case 'bidding_opened': + return 'default' + case 'bidding_closed': + return 'outline' + default: + return 'outline' + } +} + +// 금액 포맷팅 +const formatCurrency = (amount: string | number | null, currency = 'KRW') => { + if (!amount) return '-' + + const numAmount = typeof amount === 'string' ? parseFloat(amount) : amount + if (isNaN(numAmount)) return '-' + + return new Intl.NumberFormat('ko-KR', { + style: 'currency', + currency: currency, + minimumFractionDigits: 0, + maximumFractionDigits: 0, + }).format(numAmount) +} + +export function getBiddingsReceiveColumns({ setRowAction, onParticipantClick }: GetColumnsProps): ColumnDef<BiddingReceiveItem>[] { + + return [ + // ░░░ 선택 ░░░ + { + id: "select", + header: "", + cell: ({ row }) => ( + <Checkbox + checked={row.getIsSelected()} + onCheckedChange={(value) => { + // single select 모드에서는 다른 행들의 선택을 해제 + row.toggleSelected(!!value) + }} + aria-label="행 선택" + /> + ), + size: 50, + enableSorting: false, + enableHiding: false, + }, + + // ░░░ 입찰번호 ░░░ + { + accessorKey: "biddingNumber", + header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="입찰번호" />, + cell: ({ row }) => ( + <div className="font-mono text-sm"> + {row.original.biddingNumber} + </div> + ), + size: 120, + meta: { excelHeader: "입찰번호" }, + }, + + // ░░░ 입찰명 ░░░ + { + accessorKey: "title", + header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="입찰명" />, + cell: ({ row }) => ( + <div className="truncate max-w-[200px]" title={row.original.title}> + {/* <Button + variant="link" + className="p-0 h-auto text-left justify-start font-bold underline" + onClick={() => setRowAction({ row, type: "view" })} + > + <div className="whitespace-pre-line"> + {row.original.title} + </div> + </Button> */} + {row.original.title} + </div> + ), + size: 200, + meta: { excelHeader: "입찰명" }, + }, + + // ░░░ 원입찰번호 ░░░ + { + accessorKey: "originalBiddingNumber", + header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="원입찰번호" />, + cell: ({ row }) => ( + <div className="font-mono text-sm"> + {row.original.originalBiddingNumber || '-'} + </div> + ), + size: 120, + meta: { excelHeader: "원입찰번호" }, + }, + + // ░░░ 진행상태 ░░░ + { + accessorKey: "status", + header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="진행상태" />, + cell: ({ row }) => ( + <Badge variant={getStatusBadgeVariant(row.original.status)}> + {biddingStatusLabels[row.original.status]} + </Badge> + ), + size: 120, + meta: { excelHeader: "진행상태" }, + }, + + // ░░░ 계약구분 ░░░ + { + accessorKey: "contractType", + header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="계약구분" />, + cell: ({ row }) => ( + <Badge variant="outline"> + {contractTypeLabels[row.original.contractType]} + </Badge> + ), + size: 100, + meta: { excelHeader: "계약구분" }, + }, + + // ░░░ 입찰서제출기간 ░░░ + { + id: "submissionPeriod", + header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="입찰서제출기간" />, + cell: ({ row }) => { + const startDate = row.original.submissionStartDate + const endDate = row.original.submissionEndDate + + if (!startDate || !endDate) return <span className="text-muted-foreground">-</span> + + const startObj = new Date(startDate) + const endObj = new Date(endDate) + + // 입력값 기반: 저장된 UTC 값 그대로 표시 (타임존 가감 없음) + const formatValue = (d: Date) => d.toISOString().slice(0, 16).replace('T', ' ') + + return ( + <div className="text-xs"> + <div> + {formatValue(startObj)} ~ {formatValue(endObj)} + </div> + </div> + ) + }, + size: 140, + meta: { excelHeader: "입찰서제출기간" }, + }, + + // ░░░ P/R번호 ░░░ + { + accessorKey: "prNumber", + header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="P/R번호" />, + cell: ({ row }) => ( + <span className="font-mono text-sm">{row.original.prNumber || '-'}</span> + ), + size: 100, + meta: { excelHeader: "P/R번호" }, + }, + + // ░░░ 입찰담당자 ░░░ + { + accessorKey: "bidPicName", + header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="입찰담당자" />, + cell: ({ row }) => { + const bidPic = row.original.bidPicName + const supplyPic = row.original.supplyPicName + + const displayName = bidPic || supplyPic || "-" + return <span className="text-sm">{displayName}</span> + }, + size: 100, + meta: { excelHeader: "입찰담당자" }, + }, + + // ░░░ 참여예정협력사 ░░░ + { + id: "participantExpected", + header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="참여예정협력사" />, + cell: ({ row }) => ( + <Button + variant="ghost" + size="sm" + className="h-auto p-1 hover:bg-blue-50" + onClick={() => onParticipantClick?.(row.original.id, 'expected')} + disabled={row.original.participantExpected === 0} + > + <div className="flex items-center gap-1"> + <Users className="h-4 w-4 text-blue-500" /> + <span className="text-sm font-medium">{row.original.participantExpected}</span> + </div> + </Button> + ), + size: 100, + meta: { excelHeader: "참여예정협력사" }, + }, + + // ░░░ 참여협력사 ░░░ + { + id: "participantParticipated", + header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="참여협력사" />, + cell: ({ row }) => ( + <Button + variant="ghost" + size="sm" + className="h-auto p-1 hover:bg-green-50" + onClick={() => onParticipantClick?.(row.original.id, 'participated')} + disabled={row.original.participantParticipated === 0} + > + <div className="flex items-center gap-1"> + <CheckCircle className="h-4 w-4 text-green-500" /> + <span className="text-sm font-medium">{row.original.participantParticipated}</span> + </div> + </Button> + ), + size: 100, + meta: { excelHeader: "참여협력사" }, + }, + + // ░░░ 포기협력사 ░░░ + { + id: "participantDeclined", + header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="포기협력사" />, + cell: ({ row }) => ( + <Button + variant="ghost" + size="sm" + className="h-auto p-1 hover:bg-red-50" + onClick={() => onParticipantClick?.(row.original.id, 'declined')} + disabled={row.original.participantDeclined === 0} + > + <div className="flex items-center gap-1"> + <XCircle className="h-4 w-4 text-red-500" /> + <span className="text-sm font-medium">{row.original.participantDeclined}</span> + </div> + </Button> + ), + size: 100, + meta: { excelHeader: "포기협력사" }, + }, + + // ░░░ 미제출협력사 ░░░ + { + id: "participantPending", + header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="미제출협력사" />, + cell: ({ row }) => ( + <Button + variant="ghost" + size="sm" + className="h-auto p-1 hover:bg-yellow-50" + onClick={() => onParticipantClick?.(row.original.id, 'pending')} + disabled={row.original.participantPending === 0} + > + <div className="flex items-center gap-1"> + <Clock className="h-4 w-4 text-yellow-500" /> + <span className="text-sm font-medium">{row.original.participantPending}</span> + </div> + </Button> + ), + size: 100, + meta: { excelHeader: "미제출협력사" }, + }, + + // ░░░ 개찰자명 ░░░ + { + id: "openedBy", + header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="개찰자명" />, + cell: ({ row }) => { + const openedBy = row.original.openedBy + return <span className="text-sm">{openedBy || '-'}</span> + }, + size: 100, + meta: { excelHeader: "개찰자명" }, + }, + + // ░░░ 개찰일 ░░░ + { + id: "openedAt", + header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="개찰일" />, + cell: ({ row }) => { + const openedAt = row.original.openedAt + return <span className="text-sm">{openedAt ? formatDate(openedAt, "KR") : '-'}</span> + }, + size: 100, + meta: { excelHeader: "개찰일" }, + }, + + // ░░░ 등록자 ░░░ + { + accessorKey: "createdBy", + header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="등록자" />, + cell: ({ row }) => ( + <span className="text-sm">{row.original.createdBy || '-'}</span> + ), + size: 100, + meta: { excelHeader: "등록자" }, + }, + + // ░░░ 등록일시 ░░░ + { + accessorKey: "createdAt", + header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="등록일시" />, + cell: ({ row }) => ( + <span className="text-sm">{row.original.createdAt ? formatDate(row.original.createdAt, "KR") : '-'}</span> + ), + size: 100, + meta: { excelHeader: "등록일시" }, + }, + + // ═══════════════════════════════════════════════════════════════ + // 액션 + // ═══════════════════════════════════════════════════════════════ + // { + // id: "actions", + // header: "액션", + // cell: ({ row }) => ( + // <DropdownMenu> + // <DropdownMenuTrigger asChild> + // <Button variant="ghost" className="h-8 w-8 p-0"> + // <span className="sr-only">메뉴 열기</span> + // <AlertTriangle className="h-4 w-4" /> + // </Button> + // </DropdownMenuTrigger> + // <DropdownMenuContent align="end"> + // <DropdownMenuItem onClick={() => setRowAction({ row, type: "view" })}> + // <Eye className="mr-2 h-4 w-4" /> + // 상세보기 + // </DropdownMenuItem> + // </DropdownMenuContent> + // </DropdownMenu> + // ), + // size: 50, + // enableSorting: false, + // enableHiding: false, + // }, + ] +} diff --git a/lib/bidding/receive/biddings-receive-table.tsx b/lib/bidding/receive/biddings-receive-table.tsx index 2b141d5e..6a48fa79 100644 --- a/lib/bidding/receive/biddings-receive-table.tsx +++ b/lib/bidding/receive/biddings-receive-table.tsx @@ -1,296 +1,297 @@ -"use client"
-
-import * as React from "react"
-import { useRouter } from "next/navigation"
-import { useSession } from "next-auth/react"
-import type {
- DataTableAdvancedFilterField,
- DataTableFilterField,
- DataTableRowAction,
-} from "@/types/table"
-import { Button } from "@/components/ui/button"
-import { Loader2 } from "lucide-react"
-import { toast } from "sonner"
-
-import { useDataTable } from "@/hooks/use-data-table"
-import { DataTable } from "@/components/data-table/data-table"
-import { DataTableAdvancedToolbar } from "@/components/data-table/data-table-advanced-toolbar"
-import { getBiddingsReceiveColumns } from "./biddings-receive-columns"
-import { getBiddingsForReceive } from "@/lib/bidding/service"
-import {
- biddingStatusLabels,
- contractTypeLabels,
-} from "@/db/schema"
-// import { SpecificationMeetingDialog, PrDocumentsDialog } from "../list/bidding-detail-dialogs"
-import { openBiddingAction } from "@/lib/bidding/actions"
-import { BiddingParticipantsDialog } from "@/components/bidding/receive/bidding-participants-dialog"
-import { getAllBiddingCompanies } from "@/lib/bidding/detail/service"
-
-type BiddingReceiveItem = {
- id: number
- biddingNumber: string
- originalBiddingNumber: string | null
- title: string
- status: string
- contractType: string
- prNumber: string | null
- submissionStartDate: Date | null
- submissionEndDate: Date | null
- bidPicName: string | null
- supplyPicName: string | null
- createdBy: string | null
- createdAt: Date | null
- updatedAt: Date | null
-
- // 참여 현황
- participantExpected: number
- participantParticipated: number
- participantDeclined: number
- participantPending: number
- participantFinalSubmitted: number
-
- // 개찰 정보
- openedAt: Date | null
- openedBy: string | null
-}
-
-interface BiddingsReceiveTableProps {
- promises: Promise<
- [
- Awaited<ReturnType<typeof getBiddingsForReceive>>
- ]
- >
-}
-
-export function BiddingsReceiveTable({ promises }: BiddingsReceiveTableProps) {
- const [biddingsResult] = React.use(promises)
-
- // biddingsResult에서 data와 pageCount 추출
- const { data, pageCount } = biddingsResult
-
- const [isCompact, setIsCompact] = React.useState<boolean>(false)
- // const [specMeetingDialogOpen, setSpecMeetingDialogOpen] = React.useState(false)
- // const [prDocumentsDialogOpen, setPrDocumentsDialogOpen] = React.useState(false)
- const [selectedBidding, setSelectedBidding] = React.useState<BiddingReceiveItem | null>(null)
-
- const [rowAction, setRowAction] = React.useState<DataTableRowAction<BiddingReceiveItem> | null>(null)
- const [isOpeningBidding, setIsOpeningBidding] = React.useState(false)
-
- // 협력사 다이얼로그 관련 상태
- const [participantsDialogOpen, setParticipantsDialogOpen] = React.useState(false)
- const [selectedParticipantType, setSelectedParticipantType] = React.useState<'expected' | 'participated' | 'declined' | 'pending' | null>(null)
- const [selectedBiddingId, setSelectedBiddingId] = React.useState<number | null>(null)
- const [participantCompanies, setParticipantCompanies] = React.useState<any[]>([])
- const [isLoadingParticipants, setIsLoadingParticipants] = React.useState(false)
-
- const router = useRouter()
- const { data: session } = useSession()
-
- // 협력사 클릭 핸들러
- const handleParticipantClick = React.useCallback(async (biddingId: number, participantType: 'expected' | 'participated' | 'declined' | 'pending') => {
- setSelectedBiddingId(biddingId)
- setSelectedParticipantType(participantType)
- setIsLoadingParticipants(true)
- setParticipantsDialogOpen(true)
-
- try {
- // 협력사 데이터 로드 (모든 초대된 협력사)
- const companies = await getAllBiddingCompanies(biddingId)
-
- console.log('Loaded companies:', companies)
-
- // 필터링 없이 모든 데이터 그대로 표시
- // invitationStatus가 그대로 다이얼로그에 표시됨
- setParticipantCompanies(companies)
- } catch (error) {
- console.error('Failed to load participant companies:', error)
- toast.error('협력사 목록을 불러오는데 실패했습니다.')
- setParticipantCompanies([])
- } finally {
- setIsLoadingParticipants(false)
- }
- }, [])
-
- const columns = React.useMemo(
- () => getBiddingsReceiveColumns({ setRowAction, onParticipantClick: handleParticipantClick }),
- [setRowAction, handleParticipantClick]
- )
-
- // rowAction 변경 감지하여 해당 다이얼로그 열기
- React.useEffect(() => {
- if (rowAction) {
- setSelectedBidding(rowAction.row.original)
-
- switch (rowAction.type) {
- case "view":
- // 상세 페이지로 이동
- router.push(`/evcp/bid/${rowAction.row.original.id}`)
- break
- default:
- break
- }
- }
- }, [rowAction, router])
-
- const filterFields: DataTableFilterField<BiddingReceiveItem>[] = [
- {
- id: "biddingNumber",
- label: "입찰번호",
- placeholder: "입찰번호를 입력하세요",
- },
- {
- id: "prNumber",
- label: "P/R번호",
- placeholder: "P/R번호를 입력하세요",
- },
- {
- id: "title",
- label: "입찰명",
- placeholder: "입찰명을 입력하세요",
- },
- ]
-
- const advancedFilterFields: DataTableAdvancedFilterField<BiddingReceiveItem>[] = [
- { id: "title", label: "입찰명", type: "text" },
- { id: "biddingNumber", label: "입찰번호", type: "text" },
- { id: "bidPicName", label: "입찰담당자", type: "text" },
- {
- id: "status",
- label: "진행상태",
- type: "multi-select",
- options: Object.entries(biddingStatusLabels).map(([value, label]) => ({
- label,
- value,
- })),
- },
- {
- id: "contractType",
- label: "계약구분",
- type: "select",
- options: Object.entries(contractTypeLabels).map(([value, label]) => ({
- label,
- value,
- })),
- },
- { id: "createdAt", label: "등록일", type: "date" },
- { id: "submissionStartDate", label: "제출시작일", type: "date" },
- { id: "submissionEndDate", label: "제출마감일", type: "date" },
- ]
-
- const { table } = useDataTable({
- data,
- columns,
- pageCount,
- filterFields,
- enablePinning: true,
- enableAdvancedFilter: true,
- enableRowSelection: true,
- initialState: {
- sorting: [{ id: "createdAt", desc: true }],
- columnPinning: { right: ["actions"] },
- },
- getRowId: (originalRow) => String(originalRow.id),
- shallow: false,
- clearOnDefault: true,
- })
-
- const handleCompactChange = React.useCallback((compact: boolean) => {
- setIsCompact(compact)
- }, [])
-
-
- // 선택된 행 가져오기
- const selectedRows = table.getFilteredSelectedRowModel().rows
- const selectedBiddingForAction = selectedRows.length > 0 ? selectedRows[0].original : null
-
- // 개찰 가능 여부 확인
- const canOpen = React.useMemo(() => {
- if (!selectedBiddingForAction) return false
-
- const now = new Date()
- const submissionEndDate = selectedBiddingForAction.submissionEndDate ? new Date(selectedBiddingForAction.submissionEndDate) : null
-
- // 1. 입찰 마감일이 지났으면 무조건 가능
- if (submissionEndDate && now > submissionEndDate) return true
-
- // 2. 입찰 기간 내 조기개찰 조건 확인
- // - 미제출 협력사가 0이어야 함 (참여예정 = 최종제출 + 포기)
- const participatedOrDeclined = selectedBiddingForAction.participantFinalSubmitted + selectedBiddingForAction.participantDeclined
- const isEarlyOpenPossible = participatedOrDeclined === selectedBiddingForAction.participantExpected
-
- return isEarlyOpenPossible
- }, [selectedBiddingForAction])
-
- const handleOpenBidding = React.useCallback(async () => {
- if (!selectedBiddingForAction) return
-
- setIsOpeningBidding(true)
- try {
- const result = await openBiddingAction(selectedBiddingForAction.id)
- if (result.success) {
- toast.success("개찰이 완료되었습니다.")
- // 데이터 리프레시
- window.location.reload()
- } else {
- toast.error(result.message || "개찰에 실패했습니다.")
- }
- } catch (error) {
- toast.error("개찰 중 오류가 발생했습니다.")
- } finally {
- setIsOpeningBidding(false)
- }
- }, [selectedBiddingForAction])
-
- return (
- <>
- <DataTable
- table={table}
- compact={isCompact}
- >
- <DataTableAdvancedToolbar
- table={table}
- filterFields={advancedFilterFields}
- shallow={false}
- enableCompactToggle={true}
- compactStorageKey="biddingsReceiveTableCompact"
- onCompactChange={handleCompactChange}
- >
- <div className="flex items-center gap-2">
- <Button
- variant="outline"
- size="sm"
- onClick={handleOpenBidding}
- disabled={!selectedBiddingForAction || !canOpen || isOpeningBidding}
- >
- {isOpeningBidding && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
- 개찰
- </Button>
- </div>
- </DataTableAdvancedToolbar>
- </DataTable>
-
- {/* 사양설명회 다이얼로그 */}
- {/* <SpecificationMeetingDialog
- open={specMeetingDialogOpen}
- onOpenChange={handleSpecMeetingDialogClose}
- bidding={selectedBidding}
- /> */}
-
- {/* PR 문서 다이얼로그 */}
- {/* <PrDocumentsDialog
- open={prDocumentsDialogOpen}
- onOpenChange={handlePrDocumentsDialogClose}
- bidding={selectedBidding}
- /> */}
-
- {/* 참여 협력사 다이얼로그 */}
- <BiddingParticipantsDialog
- open={participantsDialogOpen}
- onOpenChange={setParticipantsDialogOpen}
- biddingId={selectedBiddingId}
- participantType={selectedParticipantType}
- companies={participantCompanies}
- />
- </>
- )
-}
+"use client" + +import * as React from "react" +import { useRouter } from "next/navigation" +import { useSession } from "next-auth/react" +import type { + DataTableAdvancedFilterField, + DataTableFilterField, + DataTableRowAction, +} from "@/types/table" +import { Button } from "@/components/ui/button" +import { Loader2 } from "lucide-react" +import { toast } from "sonner" + +import { useDataTable } from "@/hooks/use-data-table" +import { DataTable } from "@/components/data-table/data-table" +import { DataTableAdvancedToolbar } from "@/components/data-table/data-table-advanced-toolbar" +import { getBiddingsReceiveColumns } from "./biddings-receive-columns" +import { getBiddingsForReceive } from "@/lib/bidding/service" +import { + biddingStatusLabels, + contractTypeLabels, +} from "@/db/schema" +// import { SpecificationMeetingDialog, PrDocumentsDialog } from "../list/bidding-detail-dialogs" +import { openBiddingAction } from "@/lib/bidding/actions" +import { BiddingParticipantsDialog } from "@/components/bidding/receive/bidding-participants-dialog" +import { getAllBiddingCompanies } from "@/lib/bidding/detail/service" + +type BiddingReceiveItem = { + id: number + biddingNumber: string + originalBiddingNumber: string | null + title: string + status: string + contractType: string + prNumber: string | null + submissionStartDate: Date | null + submissionEndDate: Date | null + bidPicName: string | null + supplyPicName: string | null + createdBy: string | null + createdAt: Date | null + updatedAt: Date | null + + // 참여 현황 + participantExpected: number + participantParticipated: number + participantDeclined: number + participantPending: number + participantFinalSubmitted: number + + // 개찰 정보 + openedAt: Date | null + openedBy: string | null +} + +interface BiddingsReceiveTableProps { + promises: Promise< + [ + Awaited<ReturnType<typeof getBiddingsForReceive>> + ] + > +} + +export function BiddingsReceiveTable({ promises }: BiddingsReceiveTableProps) { + const [biddingsResult] = React.use(promises) + + // biddingsResult에서 data와 pageCount 추출 + const { data, pageCount } = biddingsResult + + const [isCompact, setIsCompact] = React.useState<boolean>(false) + // const [specMeetingDialogOpen, setSpecMeetingDialogOpen] = React.useState(false) + // const [prDocumentsDialogOpen, setPrDocumentsDialogOpen] = React.useState(false) + const [selectedBidding, setSelectedBidding] = React.useState<BiddingReceiveItem | null>(null) + + const [rowAction, setRowAction] = React.useState<DataTableRowAction<BiddingReceiveItem> | null>(null) + const [isOpeningBidding, setIsOpeningBidding] = React.useState(false) + + // 협력사 다이얼로그 관련 상태 + const [participantsDialogOpen, setParticipantsDialogOpen] = React.useState(false) + const [selectedParticipantType, setSelectedParticipantType] = React.useState<'expected' | 'participated' | 'declined' | 'pending' | null>(null) + const [selectedBiddingId, setSelectedBiddingId] = React.useState<number | null>(null) + const [participantCompanies, setParticipantCompanies] = React.useState<any[]>([]) + const [isLoadingParticipants, setIsLoadingParticipants] = React.useState(false) + + const router = useRouter() + const { data: session } = useSession() + + // 협력사 클릭 핸들러 + const handleParticipantClick = React.useCallback(async (biddingId: number, participantType: 'expected' | 'participated' | 'declined' | 'pending') => { + setSelectedBiddingId(biddingId) + setSelectedParticipantType(participantType) + setIsLoadingParticipants(true) + setParticipantsDialogOpen(true) + + try { + // 협력사 데이터 로드 (모든 초대된 협력사) + const companies = await getAllBiddingCompanies(biddingId) + + console.log('Loaded companies:', companies) + + // 필터링 없이 모든 데이터 그대로 표시 + // invitationStatus가 그대로 다이얼로그에 표시됨 + setParticipantCompanies(companies) + } catch (error) { + console.error('Failed to load participant companies:', error) + toast.error('협력사 목록을 불러오는데 실패했습니다.') + setParticipantCompanies([]) + } finally { + setIsLoadingParticipants(false) + } + }, []) + + const columns = React.useMemo( + () => getBiddingsReceiveColumns({ setRowAction, onParticipantClick: handleParticipantClick }), + [setRowAction, handleParticipantClick] + ) + + // rowAction 변경 감지하여 해당 다이얼로그 열기 + React.useEffect(() => { + if (rowAction) { + setSelectedBidding(rowAction.row.original) + + switch (rowAction.type) { + case "view": + // 상세 페이지로 이동 + router.push(`/evcp/bid/${rowAction.row.original.id}`) + break + default: + break + } + } + }, [rowAction, router]) + + const filterFields: DataTableFilterField<BiddingReceiveItem>[] = [ + { + id: "biddingNumber", + label: "입찰번호", + placeholder: "입찰번호를 입력하세요", + }, + { + id: "prNumber", + label: "P/R번호", + placeholder: "P/R번호를 입력하세요", + }, + { + id: "title", + label: "입찰명", + placeholder: "입찰명을 입력하세요", + }, + ] + + const advancedFilterFields: DataTableAdvancedFilterField<BiddingReceiveItem>[] = [ + { id: "title", label: "입찰명", type: "text" }, + { id: "biddingNumber", label: "입찰번호", type: "text" }, + { id: "bidPicName", label: "입찰담당자", type: "text" }, + { + id: "status", + label: "진행상태", + type: "multi-select", + options: Object.entries(biddingStatusLabels).map(([value, label]) => ({ + label, + value, + })), + }, + { + id: "contractType", + label: "계약구분", + type: "select", + options: Object.entries(contractTypeLabels).map(([value, label]) => ({ + label, + value, + })), + }, + { id: "createdAt", label: "등록일", type: "date" }, + { id: "submissionStartDate", label: "제출시작일", type: "date" }, + { id: "submissionEndDate", label: "제출마감일", type: "date" }, + ] + + const { table } = useDataTable({ + data, + columns, + pageCount, + filterFields, + enablePinning: true, + enableAdvancedFilter: true, + enableRowSelection: true, + enableMultiRowSelection: false, // 단일 선택만 가능 + initialState: { + sorting: [{ id: "createdAt", desc: true }], + columnPinning: { right: ["actions"] }, + }, + getRowId: (originalRow) => String(originalRow.id), + shallow: false, + clearOnDefault: true, + }) + + const handleCompactChange = React.useCallback((compact: boolean) => { + setIsCompact(compact) + }, []) + + + // 선택된 행 가져오기 + const selectedRows = table.getFilteredSelectedRowModel().rows + const selectedBiddingForAction = selectedRows.length > 0 ? selectedRows[0].original : null + + // 개찰 가능 여부 확인 + const canOpen = React.useMemo(() => { + if (!selectedBiddingForAction) return false + + const now = new Date() + const submissionEndDate = selectedBiddingForAction.submissionEndDate ? new Date(selectedBiddingForAction.submissionEndDate) : null + + // 1. 입찰 마감일이 지났으면 무조건 가능 + if (submissionEndDate && now > submissionEndDate) return true + + // 2. 입찰 기간 내 조기개찰 조건 확인 + // - 미제출 협력사가 0이어야 함 (참여예정 = 최종제출 + 포기) + const participatedOrDeclined = selectedBiddingForAction.participantFinalSubmitted + selectedBiddingForAction.participantDeclined + const isEarlyOpenPossible = participatedOrDeclined === selectedBiddingForAction.participantExpected + + return isEarlyOpenPossible + }, [selectedBiddingForAction]) + + const handleOpenBidding = React.useCallback(async () => { + if (!selectedBiddingForAction) return + + setIsOpeningBidding(true) + try { + const result = await openBiddingAction(selectedBiddingForAction.id) + if (result.success) { + toast.success("개찰이 완료되었습니다.") + // 데이터 리프레시 + window.location.reload() + } else { + toast.error(result.message || "개찰에 실패했습니다.") + } + } catch (error) { + toast.error("개찰 중 오류가 발생했습니다.") + } finally { + setIsOpeningBidding(false) + } + }, [selectedBiddingForAction]) + + return ( + <> + <DataTable + table={table} + compact={isCompact} + > + <DataTableAdvancedToolbar + table={table} + filterFields={advancedFilterFields} + shallow={false} + enableCompactToggle={true} + compactStorageKey="biddingsReceiveTableCompact" + onCompactChange={handleCompactChange} + > + <div className="flex items-center gap-2"> + <Button + variant="outline" + size="sm" + onClick={handleOpenBidding} + disabled={!selectedBiddingForAction || !canOpen || isOpeningBidding} + > + {isOpeningBidding && <Loader2 className="mr-2 h-4 w-4 animate-spin" />} + 개찰 + </Button> + </div> + </DataTableAdvancedToolbar> + </DataTable> + + {/* 사양설명회 다이얼로그 */} + {/* <SpecificationMeetingDialog + open={specMeetingDialogOpen} + onOpenChange={handleSpecMeetingDialogClose} + bidding={selectedBidding} + /> */} + + {/* PR 문서 다이얼로그 */} + {/* <PrDocumentsDialog + open={prDocumentsDialogOpen} + onOpenChange={handlePrDocumentsDialogClose} + bidding={selectedBidding} + /> */} + + {/* 참여 협력사 다이얼로그 */} + <BiddingParticipantsDialog + open={participantsDialogOpen} + onOpenChange={setParticipantsDialogOpen} + biddingId={selectedBiddingId} + participantType={selectedParticipantType} + companies={participantCompanies} + /> + </> + ) +} diff --git a/lib/bidding/selection/actions.ts b/lib/bidding/selection/actions.ts index f19fbe6d..06dcbea1 100644 --- a/lib/bidding/selection/actions.ts +++ b/lib/bidding/selection/actions.ts @@ -131,6 +131,75 @@ export async function saveSelectionResult(data: SaveSelectionResultData) { } } +// 선정결과 조회 +export async function getSelectionResult(biddingId: number) { + try { + // 선정결과 조회 (selectedCompanyId가 null인 레코드) + const allResults = await db + .select() + .from(vendorSelectionResults) + .where(eq(vendorSelectionResults.biddingId, biddingId)) + + // @ts-ignore + const existingResult = allResults.filter((result: any) => result.selectedCompanyId === null).slice(0, 1) + + if (existingResult.length === 0) { + return { + success: true, + data: { + summary: '', + attachments: [] + } + } + } + + const result = existingResult[0] + + // 첨부파일 조회 + const documents = await db + .select({ + id: biddingDocuments.id, + fileName: biddingDocuments.fileName, + originalFileName: biddingDocuments.originalFileName, + fileSize: biddingDocuments.fileSize, + mimeType: biddingDocuments.mimeType, + filePath: biddingDocuments.filePath, + uploadedAt: biddingDocuments.uploadedAt + }) + .from(biddingDocuments) + .where(and( + eq(biddingDocuments.biddingId, biddingId), + eq(biddingDocuments.documentType, 'selection_result') + )) + + return { + success: true, + data: { + summary: result.evaluationSummary || '', + attachments: documents.map(doc => ({ + id: doc.id, + fileName: doc.fileName || doc.originalFileName || '', + originalFileName: doc.originalFileName || '', + fileSize: doc.fileSize || 0, + mimeType: doc.mimeType || '', + filePath: doc.filePath || '', + uploadedAt: doc.uploadedAt + })) + } + } + } catch (error) { + console.error('Failed to get selection result:', error) + return { + success: false, + error: '선정결과 조회 중 오류가 발생했습니다.', + data: { + summary: '', + attachments: [] + } + } + } +} + // 견적 히스토리 조회 export async function getQuotationHistory(biddingId: number, vendorId: number) { try { @@ -168,12 +237,14 @@ export async function getQuotationHistory(biddingId: number, vendorId: number) { .where(eq(biddings.originalBiddingNumber, baseNumber)) .orderBy(biddings.createdAt) - // 각 bidding에 대한 벤더의 견적 정보 조회 + // 각 bidding에 대한 벤더의 견적 정보 및 상세 아이템 조회 const historyPromises = relatedBiddings.map(async (bidding) => { + // 1. 견적 헤더 정보 조회 (ID 포함) const biddingCompanyData = await db .select({ + id: biddingCompanies.id, finalQuoteAmount: biddingCompanies.finalQuoteAmount, - responseSubmittedAt: biddingCompanies.responseSubmittedAt, + responseSubmittedAt: biddingCompanies.finalQuoteSubmittedAt, isFinalSubmission: biddingCompanies.isFinalSubmission }) .from(biddingCompanies) @@ -187,84 +258,72 @@ export async function getQuotationHistory(biddingId: number, vendorId: number) { return null } - return { - biddingId: bidding.id, - biddingNumber: bidding.biddingNumber, - finalQuoteAmount: biddingCompanyData[0].finalQuoteAmount, - responseSubmittedAt: biddingCompanyData[0].responseSubmittedAt, - isFinalSubmission: biddingCompanyData[0].isFinalSubmission, - targetPrice: bidding.targetPrice, - currency: bidding.currency - } - }) - - const historyData = (await Promise.all(historyPromises)).filter(Boolean) - - // biddingNumber의 suffix를 기준으로 정렬 (-01, -02, -03 등) - const sortedHistory = historyData.sort((a, b) => { - const aSuffix = a!.biddingNumber.split('-')[1] ? parseInt(a!.biddingNumber.split('-')[1]) : 0 - const bSuffix = b!.biddingNumber.split('-')[1] ? parseInt(b!.biddingNumber.split('-')[1]) : 0 - return aSuffix - bSuffix - }) - - // PR 항목 정보 조회 (현재 bidding 기준) - const prItems = await db - .select({ - id: prItemsForBidding.id, - itemNumber: prItemsForBidding.itemNumber, - itemInfo: prItemsForBidding.itemInfo, - quantity: prItemsForBidding.quantity, - quantityUnit: prItemsForBidding.quantityUnit, - requestedDeliveryDate: prItemsForBidding.requestedDeliveryDate - }) - .from(prItemsForBidding) - .where(eq(prItemsForBidding.biddingId, biddingId)) - - // 각 히스토리 항목에 대한 PR 아이템 견적 조회 - const history = await Promise.all(sortedHistory.map(async (item, index) => { - // 각 bidding에 대한 PR 아이템 견적 조회 + // 2. 아이템별 견적 및 상세 정보 조회 (Join 사용) const prItemBids = await db .select({ - prItemId: companyPrItemBids.prItemId, + // 견적 정보 bidUnitPrice: companyPrItemBids.bidUnitPrice, bidAmount: companyPrItemBids.bidAmount, - proposedDeliveryDate: companyPrItemBids.proposedDeliveryDate + proposedDeliveryDate: companyPrItemBids.proposedDeliveryDate, + // 아이템 상세 정보 + prItemId: prItemsForBidding.id, + itemNumber: prItemsForBidding.itemNumber, + itemInfo: prItemsForBidding.itemInfo, + quantity: prItemsForBidding.quantity, + quantityUnit: prItemsForBidding.quantityUnit, + requestedDeliveryDate: prItemsForBidding.requestedDeliveryDate }) .from(companyPrItemBids) - .where(and( - eq(companyPrItemBids.biddingId, item!.biddingId), - eq(companyPrItemBids.companyId, vendorId) - )) + .innerJoin(prItemsForBidding, eq(companyPrItemBids.prItemId, prItemsForBidding.id)) + .where(eq(companyPrItemBids.biddingCompanyId, biddingCompanyData[0].id)) - const targetPrice = item!.targetPrice ? parseFloat(item!.targetPrice.toString()) : null - const totalAmount = parseFloat(item!.finalQuoteAmount.toString()) + // 아이템 매핑 + const items = prItemBids.map(bid => ({ + itemCode: bid.itemNumber || `ITEM${bid.prItemId}`, + itemName: bid.itemInfo || '품목 정보 없음', + quantity: bid.quantity ? parseFloat(bid.quantity.toString()) : 0, + unit: bid.quantityUnit || 'EA', + unitPrice: bid.bidUnitPrice ? parseFloat(bid.bidUnitPrice.toString()) : 0, + totalPrice: bid.bidAmount ? parseFloat(bid.bidAmount.toString()) : 0, + deliveryDate: bid.proposedDeliveryDate + ? new Date(bid.proposedDeliveryDate) + : bid.requestedDeliveryDate + ? new Date(bid.requestedDeliveryDate) + : new Date() + })) + + const targetPrice = bidding.targetPrice ? parseFloat(bidding.targetPrice.toString()) : null + const totalAmount = parseFloat(biddingCompanyData[0].finalQuoteAmount.toString()) const vsTargetPrice = targetPrice && targetPrice > 0 ? ((totalAmount - targetPrice) / targetPrice) * 100 : 0 - const items = prItemBids.map(bid => { - const prItem = prItems.find(p => p.id === bid.prItemId) - return { - itemCode: prItem?.itemNumber || `ITEM${bid.prItemId}`, - itemName: prItem?.itemInfo || '품목 정보 없음', - quantity: prItem?.quantity || 0, - unit: prItem?.quantityUnit || 'EA', - unitPrice: parseFloat(bid.bidUnitPrice.toString()), - totalPrice: parseFloat(bid.bidAmount.toString()), - deliveryDate: bid.proposedDeliveryDate ? new Date(bid.proposedDeliveryDate) : prItem?.requestedDeliveryDate ? new Date(prItem.requestedDeliveryDate) : new Date() - } - }) - return { - id: item!.biddingId, - round: index + 1, // 1차, 2차, 3차... - submittedAt: new Date(item!.responseSubmittedAt), + biddingId: bidding.id, + biddingNumber: bidding.biddingNumber, + submittedAt: new Date(biddingCompanyData[0].responseSubmittedAt), totalAmount, - currency: item!.currency || 'KRW', + currency: bidding.currency || 'KRW', vsTargetPrice: parseFloat(vsTargetPrice.toFixed(2)), items } + }) + + const historyData = (await Promise.all(historyPromises)).filter(Boolean) + + // biddingNumber의 suffix를 기준으로 정렬 (-01, -02, -03 등) + const sortedHistory = historyData.sort((a, b) => { + const aSuffix = a!.biddingNumber.split('-')[1] ? parseInt(a!.biddingNumber.split('-')[1]) : 0 + const bSuffix = b!.biddingNumber.split('-')[1] ? parseInt(b!.biddingNumber.split('-')[1]) : 0 + return aSuffix - bSuffix + }) + + // 회차 정보 추가 + const history = sortedHistory.map((item, index) => ({ + id: item!.biddingId, + round: index + 1, // 1차, 2차, 3차... + ...item! })) return { diff --git a/lib/bidding/selection/bidding-info-card.tsx b/lib/bidding/selection/bidding-info-card.tsx index 8864e7db..5904bf65 100644 --- a/lib/bidding/selection/bidding-info-card.tsx +++ b/lib/bidding/selection/bidding-info-card.tsx @@ -5,6 +5,18 @@ import { Badge } from '@/components/ui/badge' // import { formatDate } from '@/lib/utils' import { biddingStatusLabels, contractTypeLabels } from '@/db/schema' +// 입찰유형 라벨 맵 추가 +const biddingTypeLabels: Record<string, string> = { + equipment: '기자재', + construction: '공사', + service: '용역', + lease: '임차', + transport: '운송', + waste: '폐기물', + sale: '매각', + other: '기타(직접입력)', +} + interface BiddingInfoCardProps { bidding: Bidding } @@ -56,7 +68,7 @@ export function BiddingInfoCard({ bidding }: BiddingInfoCardProps) { 입찰유형 </label> <div className="text-sm font-medium"> - {bidding.isPublic ? '공개입찰' : '비공개입찰'} + {biddingTypeLabels[bidding.biddingType as keyof typeof biddingTypeLabels] || bidding.biddingType || '-'} </div> </div> diff --git a/lib/bidding/selection/bidding-item-table.tsx b/lib/bidding/selection/bidding-item-table.tsx new file mode 100644 index 00000000..aa2b34ec --- /dev/null +++ b/lib/bidding/selection/bidding-item-table.tsx @@ -0,0 +1,205 @@ +'use client' + +import * as React from 'react' +import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card' +import { getBiddingSelectionItemsAndPrices } from '@/lib/bidding/service' +import { formatNumber } from '@/lib/utils' +import { ScrollArea, ScrollBar } from '@/components/ui/scroll-area' + +interface BiddingItemTableProps { + biddingId: number +} + +export function BiddingItemTable({ biddingId }: BiddingItemTableProps) { + const [data, setData] = React.useState<{ + prItems: any[] + vendorPrices: any[] + }>({ prItems: [], vendorPrices: [] }) + const [loading, setLoading] = React.useState(true) + + React.useEffect(() => { + let isMounted = true + + const loadData = async () => { + try { + setLoading(true) + const { prItems, vendorPrices } = await getBiddingSelectionItemsAndPrices(biddingId) + + if (isMounted) { + console.log('prItems', prItems) + console.log('vendorPrices', vendorPrices) + setData({ prItems, vendorPrices }) + } + } catch (error) { + console.error('Failed to load bidding items:', error) + } finally { + if (isMounted) { + setLoading(false) + } + } + } + + loadData() + + return () => { + isMounted = false + } + }, [biddingId]) + + // Memoize calculations + const totals = React.useMemo(() => { + const { prItems } = data + return { + quantity: prItems.reduce((sum, item) => sum + Number(item.quantity || 0), 0), + weight: prItems.reduce((sum, item) => sum + Number(item.totalWeight || 0), 0), + targetAmount: prItems.reduce((sum, item) => sum + Number(item.targetAmount || 0), 0) + } + }, [data.prItems]) + + const vendorTotals = React.useMemo(() => { + const { vendorPrices } = data + return vendorPrices.map(vendor => { + const total = vendor.itemPrices.reduce((sum: number, item: any) => sum + Number(item.amount || 0), 0) + return { + companyId: vendor.companyId, + totalAmount: total + } + }) + }, [data.vendorPrices]) + + if (loading) { + return ( + <Card> + <CardHeader> + <CardTitle>응찰품목</CardTitle> + </CardHeader> + <CardContent> + <div className="flex items-center justify-center py-8"> + <div className="text-sm text-muted-foreground">로딩 중...</div> + </div> + </CardContent> + </Card> + ) + } + + const { prItems, vendorPrices } = data + + + return ( + <Card> + <CardHeader> + <CardTitle>응찰품목</CardTitle> + </CardHeader> + <CardContent> + <ScrollArea className="w-full whitespace-nowrap rounded-md border"> + <div className="w-max min-w-full"> + <table className="w-full caption-bottom text-sm"> + <thead className="[&_tr]:border-b"> + {/* Header Row 1: Base Info + Vendor Groups */} + <tr className="border-b transition-colors hover:bg-muted/50 data-[state=selected]:bg-muted"> + <th className="h-12 px-4 text-center align-middle font-medium text-muted-foreground border-r" rowSpan={2}>자재번호</th> + <th className="h-12 px-4 text-center align-middle font-medium text-muted-foreground border-r" rowSpan={2}>자재내역</th> + <th className="h-12 px-4 text-center align-middle font-medium text-muted-foreground border-r" rowSpan={2}>자재내역상세</th> + <th className="h-12 px-4 text-center align-middle font-medium text-muted-foreground border-r" rowSpan={2}>구매단위</th> + <th className="h-12 px-4 text-center align-middle font-medium text-muted-foreground border-r" rowSpan={2}>수량</th> + <th className="h-12 px-4 text-center align-middle font-medium text-muted-foreground border-r" rowSpan={2}>단위</th> + <th className="h-12 px-4 text-center align-middle font-medium text-muted-foreground border-r" rowSpan={2}>총중량</th> + <th className="h-12 px-4 text-center align-middle font-medium text-muted-foreground border-r" rowSpan={2}>중량단위</th> + <th className="h-12 px-4 text-center align-middle font-medium text-muted-foreground border-r" rowSpan={2}>내정단가</th> + <th className="h-12 px-4 text-center align-middle font-medium text-muted-foreground border-r" rowSpan={2}>내정액</th> + <th className="h-12 px-4 text-center align-middle font-medium text-muted-foreground border-r" rowSpan={2}>통화</th> + + {vendorPrices.map((vendor) => ( + <th key={vendor.companyId} colSpan={4} className="h-12 px-4 text-center align-middle font-medium text-muted-foreground border-r bg-muted/20"> + {vendor.companyName} + </th> + ))} + </tr> + {/* Header Row 2: Vendor Sub-columns */} + <tr className="border-b transition-colors hover:bg-muted/50 data-[state=selected]:bg-muted"> + {vendorPrices.map((vendor) => ( + <React.Fragment key={vendor.companyId}> + <th className="h-10 px-2 text-center align-middle font-medium text-muted-foreground border-r bg-muted/10">단가</th> + <th className="h-10 px-2 text-center align-middle font-medium text-muted-foreground border-r bg-muted/10">총액</th> + <th className="h-10 px-2 text-center align-middle font-medium text-muted-foreground border-r bg-muted/10">통화</th> + <th className="h-10 px-2 text-center align-middle font-medium text-muted-foreground border-r bg-muted/10">내정액(%)</th> + </React.Fragment> + ))} + </tr> + </thead> + <tbody className="[&_tr:last-child]:border-0"> + {/* Summary Row */} + <tr className="border-b transition-colors hover:bg-muted/50 bg-muted/30 font-semibold"> + <td className="p-4 align-middle text-center border-r" colSpan={4}>합계</td> + <td className="p-4 align-middle text-right border-r">{formatNumber(totals.quantity)}</td> + <td className="p-4 align-middle text-center border-r">-</td> + <td className="p-4 align-middle text-right border-r">{formatNumber(totals.weight)}</td> + <td className="p-4 align-middle text-center border-r">-</td> + <td className="p-4 align-middle text-center border-r">-</td> + <td className="p-4 align-middle text-right border-r">{formatNumber(totals.targetAmount)}</td> + <td className="p-4 align-middle text-center border-r">KRW</td> + + {vendorPrices.map((vendor) => { + const vTotal = vendorTotals.find(t => t.companyId === vendor.companyId)?.totalAmount || 0 + const ratio = totals.targetAmount > 0 ? (vTotal / totals.targetAmount) * 100 : 0 + return ( + <React.Fragment key={vendor.companyId}> + <td className="p-4 align-middle text-center border-r">-</td> + <td className="p-4 align-middle text-right border-r">{formatNumber(vTotal)}</td> + <td className="p-4 align-middle text-center border-r">{vendor.currency}</td> + <td className="p-4 align-middle text-right border-r">{formatNumber(ratio, 0)}%</td> + </React.Fragment> + ) + })} + </tr> + + {/* Data Rows */} + {prItems.map((item) => ( + <tr key={item.id} className="border-b transition-colors hover:bg-muted/50"> + <td className="p-4 align-middle border-r">{item.materialNumber}</td> + <td className="p-4 align-middle border-r min-w-[150px]">{item.materialInfo}</td> + <td className="p-4 align-middle border-r min-w-[150px]">{item.specification}</td> + <td className="p-4 align-middle text-center border-r">{item.purchaseUnit}</td> + <td className="p-4 align-middle text-right border-r">{formatNumber(item.quantity)}</td> + <td className="p-4 align-middle text-center border-r">{item.quantityUnit}</td> + <td className="p-4 align-middle text-right border-r">{formatNumber(item.totalWeight)}</td> + <td className="p-4 align-middle text-center border-r">{item.weightUnit}</td> + <td className="p-4 align-middle text-right border-r">{formatNumber(item.targetUnitPrice)}</td> + <td className="p-4 align-middle text-right border-r">{formatNumber(item.targetAmount)}</td> + <td className="p-4 align-middle text-center border-r">{item.currency}</td> + + {vendorPrices.map((vendor) => { + const bidItem = vendor.itemPrices.find((p: any) => p.prItemId === item.id) + const bidAmount = bidItem ? bidItem.amount : 0 + const targetAmt = Number(item.targetAmount || 0) + const ratio = targetAmt > 0 && bidAmount > 0 ? (bidAmount / targetAmt) * 100 : 0 + + return ( + <React.Fragment key={vendor.companyId}> + <td className="p-4 align-middle text-right border-r bg-muted/5"> + {bidItem ? formatNumber(bidItem.unitPrice) : '-'} + </td> + <td className="p-4 align-middle text-right border-r bg-muted/5"> + {bidItem ? formatNumber(bidItem.amount) : '-'} + </td> + <td className="p-4 align-middle text-center border-r bg-muted/5"> + {vendor.currency} + </td> + <td className="p-4 align-middle text-right border-r bg-muted/5"> + {bidItem && ratio > 0 ? `${formatNumber(ratio, 0)}%` : '-'} + </td> + </React.Fragment> + ) + })} + </tr> + ))} + </tbody> + </table> + </div> + <ScrollBar orientation="horizontal" /> + </ScrollArea> + </CardContent> + </Card> + ) +} + diff --git a/lib/bidding/selection/bidding-selection-detail-content.tsx b/lib/bidding/selection/bidding-selection-detail-content.tsx index 45d5d402..887498dc 100644 --- a/lib/bidding/selection/bidding-selection-detail-content.tsx +++ b/lib/bidding/selection/bidding-selection-detail-content.tsx @@ -5,6 +5,7 @@ import { Bidding } from '@/db/schema' import { BiddingInfoCard } from './bidding-info-card' import { SelectionResultForm } from './selection-result-form' import { VendorSelectionTable } from './vendor-selection-table' +import { BiddingItemTable } from './bidding-item-table' interface BiddingSelectionDetailContentProps { biddingId: number @@ -17,6 +18,9 @@ export function BiddingSelectionDetailContent({ }: BiddingSelectionDetailContentProps) { const [refreshKey, setRefreshKey] = React.useState(0) + // 입찰평가중 상태가 아니면 읽기 전용 + const isReadOnly = bidding.status !== 'evaluation_of_bidding' + const handleRefresh = React.useCallback(() => { setRefreshKey(prev => prev + 1) }, []) @@ -27,7 +31,7 @@ export function BiddingSelectionDetailContent({ <BiddingInfoCard bidding={bidding} /> {/* 선정결과 폼 */} - <SelectionResultForm biddingId={biddingId} onSuccess={handleRefresh} /> + <SelectionResultForm biddingId={biddingId} onSuccess={handleRefresh} readOnly={isReadOnly} /> {/* 업체선정 테이블 */} <VendorSelectionTable @@ -35,7 +39,12 @@ export function BiddingSelectionDetailContent({ biddingId={biddingId} bidding={bidding} onRefresh={handleRefresh} + readOnly={isReadOnly} /> + + {/* 응찰품목 테이블 */} + <BiddingItemTable biddingId={biddingId} /> + </div> ) } diff --git a/lib/bidding/selection/biddings-selection-columns.tsx b/lib/bidding/selection/biddings-selection-columns.tsx index 87c489e3..030fc05b 100644 --- a/lib/bidding/selection/biddings-selection-columns.tsx +++ b/lib/bidding/selection/biddings-selection-columns.tsx @@ -177,14 +177,13 @@ export function getBiddingsSelectionColumns({ setRowAction }: GetColumnsProps): const startObj = new Date(startDate)
const endObj = new Date(endDate)
- // 비교로직만 유지, 색상표기/마감뱃지 제거
- // UI 표시용 KST 변환
- const formatKst = (d: Date) => new Date(d.getTime() + 9 * 60 * 60 * 1000).toISOString().slice(0, 16).replace('T', ' ')
+ // 입력값 기반: 저장된 UTC 값 그대로 표시 (타임존 가감 없음)
+ const formatValue = (d: Date) => d.toISOString().slice(0, 16).replace('T', ' ')
return (
<div className="text-xs">
<div>
- {formatKst(startObj)} ~ {formatKst(endObj)}
+ {formatValue(startObj)} ~ {formatValue(endObj)}
</div>
</div>
)
diff --git a/lib/bidding/selection/biddings-selection-table.tsx b/lib/bidding/selection/biddings-selection-table.tsx index c3990e7b..41225531 100644 --- a/lib/bidding/selection/biddings-selection-table.tsx +++ b/lib/bidding/selection/biddings-selection-table.tsx @@ -84,13 +84,13 @@ export function BiddingsSelectionTable({ promises }: BiddingsSelectionTableProps switch (rowAction.type) {
case "view":
// 상세 페이지로 이동
- // 입찰평가중일때만 상세보기 가능
- if (rowAction.row.original.status === 'evaluation_of_bidding') {
+ // 입찰평가중, 업체선정, 차수증가, 재입찰 상태일 때 상세보기 가능
+ if (['evaluation_of_bidding', 'vendor_selected', 'round_increase', 'rebidding'].includes(rowAction.row.original.status)) {
router.push(`/evcp/bid-selection/${rowAction.row.original.id}/detail`)
} else {
toast({
title: '접근 제한',
- description: '입찰평가중이 아닙니다.',
+ description: '상세보기가 불가능한 상태입니다.',
variant: 'destructive',
})
}
diff --git a/lib/bidding/selection/selection-result-form.tsx b/lib/bidding/selection/selection-result-form.tsx index 54687cc9..af6b8d43 100644 --- a/lib/bidding/selection/selection-result-form.tsx +++ b/lib/bidding/selection/selection-result-form.tsx @@ -9,8 +9,8 @@ import { Button } from '@/components/ui/button' import { Textarea } from '@/components/ui/textarea' import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from '@/components/ui/form' import { useToast } from '@/hooks/use-toast' -import { saveSelectionResult } from './actions' -import { Loader2, Save, FileText } from 'lucide-react' +import { saveSelectionResult, getSelectionResult } from './actions' +import { Loader2, Save, FileText, Download, X } from 'lucide-react' import { Dropzone, DropzoneZone, DropzoneUploadIcon, DropzoneTitle, DropzoneDescription, DropzoneInput } from '@/components/ui/dropzone' const selectionResultSchema = z.object({ @@ -22,12 +22,25 @@ type SelectionResultFormData = z.infer<typeof selectionResultSchema> interface SelectionResultFormProps { biddingId: number onSuccess: () => void + readOnly?: boolean } -export function SelectionResultForm({ biddingId, onSuccess }: SelectionResultFormProps) { +interface AttachmentInfo { + id: number + fileName: string + originalFileName: string + fileSize: number + mimeType: string + filePath: string + uploadedAt: Date | null +} + +export function SelectionResultForm({ biddingId, onSuccess, readOnly = false }: SelectionResultFormProps) { const { toast } = useToast() const [isSubmitting, setIsSubmitting] = React.useState(false) + const [isLoading, setIsLoading] = React.useState(true) const [attachmentFiles, setAttachmentFiles] = React.useState<File[]>([]) + const [existingAttachments, setExistingAttachments] = React.useState<AttachmentInfo[]>([]) const form = useForm<SelectionResultFormData>({ resolver: zodResolver(selectionResultSchema), @@ -36,10 +49,53 @@ export function SelectionResultForm({ biddingId, onSuccess }: SelectionResultFor }, }) + // 기존 선정결과 로드 + React.useEffect(() => { + const loadSelectionResult = async () => { + setIsLoading(true) + try { + const result = await getSelectionResult(biddingId) + if (result.success && result.data) { + form.reset({ + summary: result.data.summary || '', + }) + if (result.data.attachments) { + setExistingAttachments(result.data.attachments) + } + } + } catch (error) { + console.error('Failed to load selection result:', error) + toast({ + title: '로드 실패', + description: '선정결과를 불러오는데 실패했습니다.', + variant: 'destructive', + }) + } finally { + setIsLoading(false) + } + } + + loadSelectionResult() + }, [biddingId, form, toast]) + const removeAttachmentFile = (index: number) => { setAttachmentFiles(prev => prev.filter((_, i) => i !== index)) } + const removeExistingAttachment = (id: number) => { + setExistingAttachments(prev => prev.filter(att => att.id !== id)) + } + + const downloadAttachment = (filePath: string, fileName: string) => { + // 파일 다운로드 (filePath가 절대 경로인 경우) + if (filePath.startsWith('http') || filePath.startsWith('/')) { + window.open(filePath, '_blank') + } else { + // 상대 경로인 경우 + window.open(`/api/files/${filePath}`, '_blank') + } + } + const onSubmit = async (data: SelectionResultFormData) => { setIsSubmitting(true) try { @@ -74,6 +130,22 @@ export function SelectionResultForm({ biddingId, onSuccess }: SelectionResultFor } } + if (isLoading) { + return ( + <Card> + <CardHeader> + <CardTitle>선정결과</CardTitle> + </CardHeader> + <CardContent> + <div className="flex items-center justify-center py-8"> + <Loader2 className="h-6 w-6 animate-spin text-muted-foreground" /> + <span className="ml-2 text-sm text-muted-foreground">로딩 중...</span> + </div> + </CardContent> + </Card> + ) + } + return ( <Card> <CardHeader> @@ -94,6 +166,7 @@ export function SelectionResultForm({ biddingId, onSuccess }: SelectionResultFor placeholder="선정결과에 대한 요약을 입력해주세요..." className="min-h-[120px]" {...field} + disabled={readOnly} /> </FormControl> <FormMessage /> @@ -104,35 +177,83 @@ export function SelectionResultForm({ biddingId, onSuccess }: SelectionResultFor {/* 첨부파일 */} <div className="space-y-4"> <FormLabel>첨부파일</FormLabel> - <Dropzone - maxSize={10 * 1024 * 1024} // 10MB - onDropAccepted={(files) => { - const newFiles = Array.from(files) - setAttachmentFiles(prev => [...prev, ...newFiles]) - }} - onDropRejected={() => { - toast({ - title: "파일 업로드 거부", - description: "파일 크기 및 형식을 확인해주세요.", - variant: "destructive", - }) - }} - > - <DropzoneZone> - <DropzoneUploadIcon className="mx-auto h-12 w-12 text-muted-foreground" /> - <DropzoneTitle className="text-lg font-medium"> - 파일을 드래그하거나 클릭하여 업로드 - </DropzoneTitle> - <DropzoneDescription className="text-sm text-muted-foreground"> - PDF, Word, Excel, 이미지 파일 (최대 10MB) - </DropzoneDescription> - </DropzoneZone> - <DropzoneInput /> - </Dropzone> + + {/* 기존 첨부파일 */} + {existingAttachments.length > 0 && ( + <div className="space-y-2"> + <h4 className="text-sm font-medium">기존 첨부파일</h4> + <div className="space-y-2"> + {existingAttachments.map((attachment) => ( + <div + key={attachment.id} + className="flex items-center justify-between p-3 bg-muted rounded-lg" + > + <div className="flex items-center gap-3"> + <FileText className="h-4 w-4 text-muted-foreground" /> + <div> + <p className="text-sm font-medium">{attachment.originalFileName || attachment.fileName}</p> + <p className="text-xs text-muted-foreground"> + {(attachment.fileSize / 1024 / 1024).toFixed(2)} MB + </p> + </div> + </div> + <div className="flex items-center gap-2"> + <Button + type="button" + variant="ghost" + size="sm" + onClick={() => downloadAttachment(attachment.filePath, attachment.originalFileName || attachment.fileName)} + > + <Download className="h-4 w-4" /> + </Button> + {!readOnly && ( + <Button + type="button" + variant="ghost" + size="sm" + onClick={() => removeExistingAttachment(attachment.id)} + > + <X className="h-4 w-4" /> + </Button> + )} + </div> + </div> + ))} + </div> + </div> + )} + + {!readOnly && ( + <Dropzone + maxSize={10 * 1024 * 1024} // 10MB + onDropAccepted={(files) => { + const newFiles = Array.from(files) + setAttachmentFiles(prev => [...prev, ...newFiles]) + }} + onDropRejected={() => { + toast({ + title: "파일 업로드 거부", + description: "파일 크기 및 형식을 확인해주세요.", + variant: "destructive", + }) + }} + > + <DropzoneZone> + <DropzoneUploadIcon className="mx-auto h-12 w-12 text-muted-foreground" /> + <DropzoneTitle className="text-lg font-medium"> + 파일을 드래그하거나 클릭하여 업로드 + </DropzoneTitle> + <DropzoneDescription className="text-sm text-muted-foreground"> + PDF, Word, Excel, 이미지 파일 (최대 10MB) + </DropzoneDescription> + </DropzoneZone> + <DropzoneInput /> + </Dropzone> + )} {attachmentFiles.length > 0 && ( <div className="space-y-2"> - <h4 className="text-sm font-medium">업로드된 파일</h4> + <h4 className="text-sm font-medium">새로 추가할 파일</h4> <div className="space-y-2"> {attachmentFiles.map((file, index) => ( <div @@ -148,14 +269,16 @@ export function SelectionResultForm({ biddingId, onSuccess }: SelectionResultFor </p> </div> </div> - <Button - type="button" - variant="ghost" - size="sm" - onClick={() => removeAttachmentFile(index)} - > - 제거 - </Button> + {!readOnly && ( + <Button + type="button" + variant="ghost" + size="sm" + onClick={() => removeAttachmentFile(index)} + > + 제거 + </Button> + )} </div> ))} </div> @@ -164,13 +287,15 @@ export function SelectionResultForm({ biddingId, onSuccess }: SelectionResultFor </div> {/* 저장 버튼 */} - <div className="flex justify-end"> - <Button type="submit" disabled={isSubmitting}> - {isSubmitting && <Loader2 className="mr-2 h-4 w-4 animate-spin" />} - <Save className="mr-2 h-4 w-4" /> - 저장 - </Button> - </div> + {!readOnly && ( + <div className="flex justify-end"> + <Button type="submit" disabled={isSubmitting}> + {isSubmitting && <Loader2 className="mr-2 h-4 w-4 animate-spin" />} + <Save className="mr-2 h-4 w-4" /> + 저장 + </Button> + </div> + )} </form> </Form> </CardContent> diff --git a/lib/bidding/selection/vendor-selection-table.tsx b/lib/bidding/selection/vendor-selection-table.tsx index 8570b5b6..40f13ec1 100644 --- a/lib/bidding/selection/vendor-selection-table.tsx +++ b/lib/bidding/selection/vendor-selection-table.tsx @@ -10,9 +10,10 @@ interface VendorSelectionTableProps { biddingId: number bidding: Bidding onRefresh: () => void + readOnly?: boolean } -export function VendorSelectionTable({ biddingId, bidding, onRefresh }: VendorSelectionTableProps) { +export function VendorSelectionTable({ biddingId, bidding, onRefresh, readOnly = false }: VendorSelectionTableProps) { const [vendors, setVendors] = React.useState<any[]>([]) const [loading, setLoading] = React.useState(true) @@ -59,6 +60,7 @@ export function VendorSelectionTable({ biddingId, bidding, onRefresh }: VendorSe vendors={vendors} onRefresh={onRefresh} onOpenSelectionReasonDialog={() => {}} + readOnly={readOnly} /> </CardContent> </Card> diff --git a/lib/bidding/service.ts b/lib/bidding/service.ts index a658ee6a..ed20ad0c 100644 --- a/lib/bidding/service.ts +++ b/lib/bidding/service.ts @@ -18,6 +18,7 @@ import { vendorContacts, vendors } from '@/db/schema' +import { companyConditionResponses } from '@/db/schema/bidding' import { eq, desc, @@ -39,8 +40,11 @@ import { import { revalidatePath } from 'next/cache' import { filterColumns } from '@/lib/filter-columns' import { GetBiddingsSchema, CreateBiddingSchema } from './validation' -import { saveFile } from '../file-stroage' - +import { saveFile, saveBuffer } from '../file-stroage' +import { decryptBufferWithServerAction } from '@/components/drm/drmUtils' +import { getVendorPricesForBidding } from './detail/service' +import { getPrItemsForBidding } from './pre-quote/service' +import { checkChemicalSubstance, checkMultipleChemicalSubstances, type ChemicalSubstanceResult } from '@/lib/soap/ecc/send/chemical-substance-check' // 사용자 이메일로 사용자 코드 조회 @@ -59,6 +63,27 @@ export async function getUserCodeByEmail(email: string): Promise<string | null> } } +// 사용자 ID로 상세 정보 조회 (이름, 코드 등) +export async function getUserDetails(userId: number) { + try { + const user = await db + .select({ + id: users.id, + name: users.name, + userCode: users.userCode, + employeeNumber: users.employeeNumber + }) + .from(users) + .where(eq(users.id, userId)) + .limit(1) + + return user[0] || null + } catch (error) { + console.error('Failed to get user details:', error) + return null + } +} + // userId를 user.name으로 변환하는 유틸리티 함수 async function getUserNameById(userId: string): Promise<string> { try { @@ -419,9 +444,10 @@ export async function getBiddings(input: GetBiddingsSchema) { // 메타 정보 remarks: biddings.remarks, updatedAt: biddings.updatedAt, - updatedBy: biddings.updatedBy, + updatedBy: users.name, }) .from(biddings) + .leftJoin(users, sql`${biddings.updatedBy} = ${users.id}::varchar`) .where(finalWhere) .orderBy(...orderByColumns) .limit(input.perPage) @@ -846,7 +872,7 @@ export async function createBidding(input: CreateBiddingInput, userId: string) { .insert(biddings) .values({ biddingNumber, - originalBiddingNumber: null, // 원입찰번호는 초기 생성이므로 아직 없음 + originalBiddingNumber: biddingNumber.split('-')[0], revision: input.revision || 0, // 프로젝트 정보 (PR 아이템에서 설정됨) @@ -872,7 +898,6 @@ export async function createBidding(input: CreateBiddingInput, userId: string) { biddingRegistrationDate: new Date(), submissionStartDate: parseDate(input.submissionStartDate), submissionEndDate: parseDate(input.submissionEndDate), - evaluationDate: parseDate(input.evaluationDate), hasSpecificationMeeting: input.hasSpecificationMeeting || false, hasPrDocument: input.hasPrDocument || false, @@ -911,6 +936,7 @@ export async function createBidding(input: CreateBiddingInput, userId: string) { await tx.insert(biddingNoticeTemplate).values({ biddingId, title: input.title + ' 입찰공고', + type: input.noticeType || 'standard', content: input.content || standardContent, isTemplate: false, }) @@ -1721,7 +1747,6 @@ export async function updateBiddingBasicInfo( contractEndDate?: string submissionStartDate?: string submissionEndDate?: string - evaluationDate?: string hasSpecificationMeeting?: boolean hasPrDocument?: boolean currency?: string @@ -1779,9 +1804,23 @@ export async function updateBiddingBasicInfo( // 정의된 필드들만 업데이트 if (updates.title !== undefined) updateData.title = updates.title if (updates.description !== undefined) updateData.description = updates.description - if (updates.content !== undefined) updateData.content = updates.content + // content는 bidding 테이블에 컬럼이 없음, notice content는 별도로 저장해야 함 + // if (updates.content !== undefined) updateData.content = updates.content if (updates.noticeType !== undefined) updateData.noticeType = updates.noticeType if (updates.contractType !== undefined) updateData.contractType = updates.contractType + + // 입찰공고 내용 저장 + if (updates.content !== undefined) { + try { + await saveBiddingNotice(biddingId, { + title: (updates.title || '') + ' 입찰공고', // 제목이 없으면 기존 제목을 가져오거나 해야하는데, 여기서는 업데이트된 제목 사용 + content: updates.content + }) + } catch (e) { + console.error('Failed to save bidding notice content:', e) + // 공고 저장 실패는 전체 업데이트 실패로 처리하지 않음 (로그만 남김) + } + } if (updates.biddingType !== undefined) updateData.biddingType = updates.biddingType if (updates.biddingTypeCustom !== undefined) updateData.biddingTypeCustom = updates.biddingTypeCustom if (updates.awardCount !== undefined) updateData.awardCount = updates.awardCount @@ -1793,7 +1832,6 @@ export async function updateBiddingBasicInfo( if (updates.contractEndDate !== undefined) updateData.contractEndDate = parseDate(updates.contractEndDate) if (updates.submissionStartDate !== undefined) updateData.submissionStartDate = parseDate(updates.submissionStartDate) if (updates.submissionEndDate !== undefined) updateData.submissionEndDate = parseDate(updates.submissionEndDate) - if (updates.evaluationDate !== undefined) updateData.evaluationDate = parseDate(updates.evaluationDate) if (updates.hasSpecificationMeeting !== undefined) updateData.hasSpecificationMeeting = updates.hasSpecificationMeeting if (updates.hasPrDocument !== undefined) updateData.hasPrDocument = updates.hasPrDocument if (updates.currency !== undefined) updateData.currency = updates.currency @@ -1877,12 +1915,14 @@ export async function updateBiddingBasicInfo( } } -// 입찰 일정 업데이트 +// 입찰 일정 업데이트 (오프셋 기반) export async function updateBiddingSchedule( biddingId: number, schedule: { - submissionStartDate?: string - submissionEndDate?: string + submissionStartOffset?: number // 시작일 오프셋 (결재 후 n일) + submissionStartTime?: string // 시작 시간 (HH:MM) + submissionDurationDays?: number // 기간 (시작일 + n일) + submissionEndTime?: string // 마감 시간 (HH:MM) remarks?: string isUrgent?: boolean hasSpecificationMeeting?: boolean @@ -1913,14 +1953,28 @@ export async function updateBiddingSchedule( return new Date(`${dateStr}:00+09:00`) } + // 시간 문자열(HH:MM)을 임시 timestamp로 변환 (1970-01-01 HH:MM:00 UTC) + // 결재 완료 시 실제 날짜로 계산됨 + const timeToTimestamp = (timeStr?: string): Date | null => { + if (!timeStr) return null + const [hours, minutes] = timeStr.split(':').map(Number) + const date = new Date(0) // 1970-01-01 00:00:00 UTC + date.setUTCHours(hours, minutes, 0, 0) + return date + } + return await db.transaction(async (tx) => { const updateData: any = { updatedAt: new Date(), updatedBy: userName, } - if (schedule.submissionStartDate !== undefined) updateData.submissionStartDate = parseDate(schedule.submissionStartDate) || null - if (schedule.submissionEndDate !== undefined) updateData.submissionEndDate = parseDate(schedule.submissionEndDate) || null + // 오프셋 기반 필드 저장 + if (schedule.submissionStartOffset !== undefined) updateData.submissionStartOffset = schedule.submissionStartOffset + if (schedule.submissionDurationDays !== undefined) updateData.submissionDurationDays = schedule.submissionDurationDays + // 시간은 timestamp 필드에 임시 저장 (1970-01-01 HH:MM:00) + if (schedule.submissionStartTime !== undefined) updateData.submissionStartDate = timeToTimestamp(schedule.submissionStartTime) + if (schedule.submissionEndTime !== undefined) updateData.submissionEndDate = timeToTimestamp(schedule.submissionEndTime) if (schedule.remarks !== undefined) updateData.remarks = schedule.remarks if (schedule.isUrgent !== undefined) updateData.isUrgent = schedule.isUrgent if (schedule.hasSpecificationMeeting !== undefined) updateData.hasSpecificationMeeting = schedule.hasSpecificationMeeting @@ -2196,7 +2250,7 @@ export async function updateBiddingProjectInfo(biddingId: number) { } // 입찰의 PR 아이템 금액 합산하여 bidding 업데이트 -async function updateBiddingAmounts(biddingId: number) { +export async function updateBiddingAmounts(biddingId: number) { try { // 해당 bidding의 모든 PR 아이템들의 금액 합계 계산 const amounts = await db @@ -2214,9 +2268,9 @@ async function updateBiddingAmounts(biddingId: number) { await db .update(biddings) .set({ - targetPrice: totalTargetAmount, - budget: totalBudgetAmount, - finalBidPrice: totalActualAmount, + targetPrice: String(totalTargetAmount), + budget: String(totalBudgetAmount), + finalBidPrice: String(totalActualAmount), updatedAt: new Date() }) .where(eq(biddings.id, biddingId)) @@ -2511,6 +2565,119 @@ export async function deleteBiddingCompanyContact(contactId: number) { } } +// 입찰담당자별 입찰 업체 조회 +export async function getBiddingCompaniesByBidPicId(bidPicId: number) { + try { + const companies = await db + .select({ + biddingId: biddings.id, + biddingNumber: biddings.biddingNumber, + biddingTitle: biddings.title, + companyId: biddingCompanies.companyId, + vendorCode: vendors.vendorCode, + vendorName: vendors.vendorName, + updatedAt: biddings.updatedAt, + }) + .from(biddings) + .innerJoin(biddingCompanies, eq(biddings.id, biddingCompanies.biddingId)) + .innerJoin(vendors, eq(biddingCompanies.companyId, vendors.id)) + .where(eq(biddings.bidPicId, bidPicId)) + .orderBy(desc(biddings.updatedAt)) + + return { + success: true, + data: companies + } + } catch (error) { + console.error('Failed to get bidding companies by bidPicId:', error) + return { + success: false, + error: '입찰 업체 조회에 실패했습니다.', + data: [] + } + } +} + +// 입찰 업체를 현재 입찰에 추가 (담당자 정보 포함) +export async function addBiddingCompanyFromOtherBidding( + targetBiddingId: number, + sourceBiddingId: number, + companyId: number, + contacts?: Array<{ + contactName: string + contactEmail: string + contactNumber?: string + }> +) { + try { + return await db.transaction(async (tx) => { + // 중복 체크 + const existingCompany = await tx + .select() + .from(biddingCompanies) + .where( + and( + eq(biddingCompanies.biddingId, targetBiddingId), + eq(biddingCompanies.companyId, companyId) + ) + ) + .limit(1) + + if (existingCompany.length > 0) { + return { + success: false, + error: '이미 등록된 업체입니다.' + } + } + + // 1. biddingCompanies 레코드 생성 + const [biddingCompanyResult] = await tx + .insert(biddingCompanies) + .values({ + biddingId: targetBiddingId, + companyId: companyId, + invitationStatus: 'pending', + invitedAt: new Date(), + }) + .returning({ id: biddingCompanies.id }) + + if (!biddingCompanyResult) { + throw new Error('업체 추가에 실패했습니다.') + } + + // 2. 담당자 정보 추가 + if (contacts && contacts.length > 0) { + await tx.insert(biddingCompaniesContacts).values( + contacts.map(contact => ({ + biddingId: targetBiddingId, + vendorId: companyId, + contactName: contact.contactName, + contactEmail: contact.contactEmail, + contactNumber: contact.contactNumber || null, + })) + ) + } + + // 3. company_condition_responses 레코드 생성 + await tx.insert(companyConditionResponses).values({ + biddingCompanyId: biddingCompanyResult.id, + }) + + return { + success: true, + message: '업체가 성공적으로 추가되었습니다.', + data: { id: biddingCompanyResult.id } + } + }) + } catch (error) { + console.error('Failed to add bidding company from other bidding:', error) + return { + success: false, + error: error instanceof Error ? error.message : '업체 추가에 실패했습니다.' + } + } +} + export async function updateBiddingConditions( biddingId: number, updates: { @@ -2758,10 +2925,13 @@ export async function increaseRoundOrRebid(biddingId: number, userId: string | u // 2. 입찰번호 생성 (타입에 따라 다르게 처리) let newBiddingNumber: string + let originalBiddingNumber: string if (type === 'rebidding') { // 재입찰: 완전히 새로운 입찰번호 생성 newBiddingNumber = await generateBiddingNumber(existingBidding.contractType, userId, tx) + // 재입찰시에도 원입찰번호는 새로 생성된 입찰번호로 셋팅 + originalBiddingNumber = newBiddingNumber.split('-')[0] } else { // 차수증가: 기존 입찰번호에서 차수 증가 const currentBiddingNumber = existingBidding.biddingNumber @@ -2771,16 +2941,18 @@ export async function increaseRoundOrRebid(biddingId: number, userId: string | u let currentRound = match ? parseInt(match[1]) : 1 if (currentRound >= 3) { - // -03 이상이면 새로운 번호 생성 + // -03 이상이면 재입찰이며, 새로운 번호 생성 newBiddingNumber = await generateBiddingNumber(existingBidding.contractType, userId, tx) + // 새로 생성한 입찰번호를 원입찰번호로 셋팅 + originalBiddingNumber = newBiddingNumber.split('-')[0] } else { // -02까지는 차수만 증가 const baseNumber = currentBiddingNumber.split('-')[0] newBiddingNumber = `${baseNumber}-${String(currentRound + 1).padStart(2, '0')}` + // 차수증가의 경우에도 원입찰번호는 새로 생성한 입찰번호로 셋팅 + originalBiddingNumber = newBiddingNumber.split('-')[0] } } - //원입찰번호는 -0n 제외하고 저장 - const originalBiddingNumber = existingBidding.biddingNumber.split('-')[0] // 3. 새로운 입찰 생성 (기존 정보 복제) const [newBidding] = await tx @@ -2793,13 +2965,15 @@ export async function increaseRoundOrRebid(biddingId: number, userId: string | u // 기본 정보 복제 projectName: existingBidding.projectName, + projectCode: existingBidding.projectCode, // 프로젝트 코드 복제 itemName: existingBidding.itemName, title: existingBidding.title, description: existingBidding.description, // 계약 정보 복제 contractType: existingBidding.contractType, - biddingType: existingBidding.biddingType, + noticeType: existingBidding.noticeType, // 공고타입 복제 + biddingType: existingBidding.biddingType, // 구매유형 복제 awardCount: existingBidding.awardCount, contractStartDate: existingBidding.contractStartDate, contractEndDate: existingBidding.contractEndDate, @@ -2809,7 +2983,6 @@ export async function increaseRoundOrRebid(biddingId: number, userId: string | u biddingRegistrationDate: new Date(), submissionStartDate: null, submissionEndDate: null, - evaluationDate: null, // 사양설명회 hasSpecificationMeeting: existingBidding.hasSpecificationMeeting, @@ -2819,6 +2992,7 @@ export async function increaseRoundOrRebid(biddingId: number, userId: string | u budget: existingBidding.budget, targetPrice: existingBidding.targetPrice, targetPriceCalculationCriteria: existingBidding.targetPriceCalculationCriteria, + actualPrice: existingBidding.actualPrice, finalBidPrice: null, // 최종입찰가는 초기화 // PR 정보 복제 @@ -2832,6 +3006,7 @@ export async function increaseRoundOrRebid(biddingId: number, userId: string | u // 구매조직 purchasingOrganization: existingBidding.purchasingOrganization, + plant: existingBidding.plant, // 담당자 정보 복제 bidPicId: existingBidding.bidPicId, @@ -3074,8 +3249,6 @@ export async function increaseRoundOrRebid(biddingId: number, userId: string | u .from(biddingDocuments) .where(and( eq(biddingDocuments.biddingId, biddingId), - // PR 아이템에 연결된 첨부파일은 제외 (SHI용과 협력업체용만 복제) - isNull(biddingDocuments.prItemId), // SHI용(evaluation_doc) 또는 협력업체용(company_proposal) 문서만 복제 or( eq(biddingDocuments.documentType, 'evaluation_doc'), @@ -3086,32 +3259,34 @@ export async function increaseRoundOrRebid(biddingId: number, userId: string | u if (existingDocuments.length > 0) { for (const doc of existingDocuments) { try { - // 기존 파일을 Buffer로 읽어서 File 객체 생성 - const { readFileSync, existsSync } = await import('fs') + // 기존 파일 경로 확인 및 Buffer로 읽기 + const { readFile, access, constants } = await import('fs/promises') const { join } = await import('path') + // 파일 경로 정규화 const oldFilePath = doc.filePath.startsWith('/uploads/') ? join(process.cwd(), 'public', doc.filePath) + : doc.filePath.startsWith('/') + ? join(process.cwd(), 'public', doc.filePath) : doc.filePath - if (!existsSync(oldFilePath)) { - console.warn(`원본 파일이 존재하지 않음: ${oldFilePath}`) + // 파일 존재 여부 확인 + try { + await access(oldFilePath, constants.R_OK) + } catch { + console.warn(`원본 파일이 존재하지 않거나 읽을 수 없음: ${oldFilePath}`) continue } - // 파일 내용을 읽어서 Buffer 생성 - const fileBuffer = readFileSync(oldFilePath) - - // Buffer를 File 객체로 변환 (브라우저 File API 시뮬레이션) - const file = new File([fileBuffer], doc.fileName, { - type: doc.mimeType || 'application/octet-stream' - }) + // 파일 내용을 Buffer로 읽기 + const fileBuffer = await readFile(oldFilePath) - // saveFile을 사용하여 새 파일 저장 - const saveResult = await saveFile({ - file, + // saveBuffer를 사용하여 새 파일 저장 (File 객체 변환 없이 직접 저장) + const saveResult = await saveBuffer({ + buffer: fileBuffer, + fileName: doc.fileName, directory: `biddings/${newBidding.id}/attachments/${doc.documentType === 'evaluation_doc' ? 'shi' : 'vendor'}`, - originalName: `copied_${Date.now()}_${doc.fileName}`, + originalName: doc.originalFileName || doc.fileName, userId: userName }) @@ -3145,9 +3320,10 @@ export async function increaseRoundOrRebid(biddingId: number, userId: string | u } } - revalidatePath('/bidding') - revalidatePath(`/bidding/${biddingId}`) // 기존 입찰 페이지도 갱신 - revalidatePath(`/bidding/${newBidding.id}`) + revalidatePath('/bid-receive') + revalidatePath('/evcp/bid-receive') + revalidatePath('/evcp/bid') + revalidatePath(`/bid-receive/${biddingId}`) // 기존 입찰 페이지도 갱신 return { success: true, @@ -3436,9 +3612,10 @@ export async function getBiddingsForSelection(input: GetBiddingsSchema) { // 'bidding_opened', 'bidding_closed', 'evaluation_of_bidding', 'vendor_selected' 상태만 조회 basicConditions.push( or( - eq(biddings.status, 'bidding_closed'), eq(biddings.status, 'evaluation_of_bidding'), - eq(biddings.status, 'vendor_selected') + eq(biddings.status, 'vendor_selected'), + eq(biddings.status, 'round_increase'), + eq(biddings.status, 'rebidding'), )! ) @@ -3704,7 +3881,7 @@ export async function getBiddingsForFailure(input: GetBiddingsSchema) { // 유찰 정보 (업데이트 일시를 유찰일로 사용) disposalDate: biddings.updatedAt, // 유찰일 disposalUpdatedAt: biddings.updatedAt, // 폐찰수정일 - disposalUpdatedBy: biddings.updatedBy, // 폐찰수정자 + disposalUpdatedBy: users.name, // 폐찰수정자 // 폐찰 정보 closureReason: biddings.description, // 폐찰사유 @@ -3719,9 +3896,10 @@ export async function getBiddingsForFailure(input: GetBiddingsSchema) { createdBy: biddings.createdBy, createdAt: biddings.createdAt, updatedAt: biddings.updatedAt, - updatedBy: biddings.updatedBy, + updatedBy: users.name, }) .from(biddings) + .leftJoin(users, sql`${biddings.updatedBy} = ${users.id}::varchar`) .leftJoin(biddingDocuments, and( eq(biddingDocuments.biddingId, biddings.id), eq(biddingDocuments.documentType, 'evaluation_doc'), // 폐찰 문서 @@ -3791,4 +3969,378 @@ export async function getBiddingsForFailure(input: GetBiddingsSchema) { console.error("Error in getBiddingsForFailure:", err) return { data: [], pageCount: 0, total: 0 } } -}
\ No newline at end of file +} + + +export async function getBiddingSelectionItemsAndPrices(biddingId: number) { + try { + const [prItems, vendorPrices] = await Promise.all([ + getPrItemsForBidding(biddingId), + getVendorPricesForBidding(biddingId) + ]) + + return { + prItems, + vendorPrices + } + } catch (error) { + console.error('Failed to get bidding selection items and prices:', error) + throw error + } +} + +// ======================================== +// 화학물질 조회 및 저장 관련 함수들 +// ======================================== + +/** + * 입찰 참여업체의 화학물질 정보를 조회하고 DB에 저장 + */ +// export async function checkAndSaveChemicalSubstanceForBiddingCompany(biddingCompanyId: number) { +// try { +// // 입찰 참여업체 정보 조회 (벤더 정보 포함) +// const biddingCompanyInfo = await db +// .select({ +// id: biddingCompanies.id, +// biddingId: biddingCompanies.biddingId, +// companyId: biddingCompanies.companyId, +// hasChemicalSubstance: biddingCompanies.hasChemicalSubstance, +// vendors: { +// vendorCode: vendors.vendorCode +// } +// }) +// .from(biddingCompanies) +// .leftJoin(vendors, eq(biddingCompanies.companyId, vendors.id)) +// .where(eq(biddingCompanies.id, biddingCompanyId)) +// .limit(1) + +// if (!biddingCompanyInfo[0]) { +// throw new Error(`입찰 참여업체를 찾을 수 없습니다: ${biddingCompanyId}`) +// } + +// const companyInfo = biddingCompanyInfo[0] + +// // 이미 화학물질 검사가 완료된 경우 스킵 +// if (companyInfo.hasChemicalSubstance !== null && companyInfo.hasChemicalSubstance !== undefined) { +// console.log(`이미 화학물질 검사가 완료된 입찰 참여업체: ${biddingCompanyId}`) +// return { +// success: true, +// message: '이미 화학물질 검사가 완료되었습니다.', +// hasChemicalSubstance: companyInfo.hasChemicalSubstance +// } +// } + +// // 벤더 코드가 없는 경우 스킵 +// if (!companyInfo.vendors?.vendorCode) { +// console.log(`벤더 코드가 없는 입찰 참여업체: ${biddingCompanyId}`) +// return { +// success: false, +// message: '벤더 코드가 없습니다.' +// } +// } + +// // 입찰의 PR 아이템들 조회 (자재번호 있는 것만) +// const prItems = await db +// .select({ +// id: prItemsForBidding.id, +// materialNumber: prItemsForBidding.materialNumber +// }) +// .from(prItemsForBidding) +// .where(and( +// eq(prItemsForBidding.biddingId, companyInfo.biddingId), +// isNotNull(prItemsForBidding.materialNumber), +// sql`${prItemsForBidding.materialNumber} != ''` +// )) + +// if (prItems.length === 0) { +// console.log(`자재번호가 있는 PR 아이템이 없는 입찰: ${companyInfo.biddingId}`) +// return { +// success: false, +// message: '조회할 자재가 없습니다.' +// } +// } + +// // 각 자재에 대해 화학물질 조회 +// let hasAnyChemicalSubstance = false +// const results: Array<{ materialNumber: string; hasChemicalSubstance: boolean; message: string }> = [] + +// for (const prItem of prItems) { +// try { +// const checkResult = await checkChemicalSubstance({ +// bukrs: 'H100', // 회사코드는 H100 고정 +// werks: 'PM11', // WERKS는 PM11 고정 +// lifnr: companyInfo.vendors.vendorCode, +// matnr: prItem.materialNumber! +// }) + +// if (checkResult.success) { +// const itemHasChemical = checkResult.hasChemicalSubstance || false +// hasAnyChemicalSubstance = hasAnyChemicalSubstance || itemHasChemical + +// results.push({ +// materialNumber: prItem.materialNumber!, +// hasChemicalSubstance: itemHasChemical, +// message: checkResult.message || '조회 성공' +// }) +// } else { +// results.push({ +// materialNumber: prItem.materialNumber!, +// hasChemicalSubstance: false, +// message: checkResult.message || '조회 실패' +// }) +// } + +// // API 호출 간 지연 +// await new Promise(resolve => setTimeout(resolve, 500)) + +// } catch (error) { +// results.push({ +// materialNumber: prItem.materialNumber!, +// hasChemicalSubstance: false, +// message: error instanceof Error ? error.message : 'Unknown error' +// }) +// } +// } + +// // 하나라도 Y(Y=true)이면 true, 모두 N(false)이면 false +// const finalHasChemicalSubstance = hasAnyChemicalSubstance + +// // DB에 결과 저장 +// await db +// .update(biddingCompanies) +// .set({ +// hasChemicalSubstance: finalHasChemicalSubstance, +// updatedAt: new Date() +// }) +// .where(eq(biddingCompanies.id, biddingCompanyId)) + +// console.log(`화학물질 정보 저장 완료: 입찰 참여업체 ${biddingCompanyId}, 화학물질 ${finalHasChemicalSubstance ? '있음' : '없음'} (${results.filter(r => r.hasChemicalSubstance).length}/${results.length})`) + +// return { +// success: true, +// message: `화학물질 조회 및 저장이 완료되었습니다. (${results.filter(r => r.hasChemicalSubstance).length}/${results.length}개 자재에 화학물질 있음)`, +// hasChemicalSubstance: finalHasChemicalSubstance, +// results +// } + +// } catch (error) { +// console.error(`화학물질 조회 실패 (입찰 참여업체 ${biddingCompanyId}):`, error) +// return { +// success: false, +// message: error instanceof Error ? error.message : 'Unknown error', +// hasChemicalSubstance: null, +// results: [] +// } +// } +// } + +/** + * 입찰의 모든 참여업체에 대한 화학물질 정보를 일괄 조회하고 저장 + */ +export async function checkAndSaveChemicalSubstancesForBidding(biddingId: number) { + try { + const [biddingInfo] = await db + .select({ + id: biddings.id, + ANFNR: biddings.ANFNR, + plant: biddings.plant, + }) + .from(biddings) + .where(eq(biddings.id, biddingId)) + .limit(1) + + if (!biddingInfo) { + return { + success: false, + message: '입찰 정보를 찾을 수 없습니다.', + results: [] + } + } + + if (!biddingInfo.ANFNR) { + return { + success: true, + message: 'SAP PR 연동 입찰이 아니므로 화학물질 검사를 건너뜁니다.', + results: [] + } + } + + const biddingWerks = biddingInfo.plant?.trim() + if (!biddingWerks) { + return { + success: false, + message: '입찰의 플랜트(WERKS) 정보가 없어 화학물질 검사를 진행할 수 없습니다.', + results: [] + } + } + + // 입찰의 모든 참여업체 조회 (벤더 코드 있는 것만) + const biddingCompaniesList = await db + .select({ + id: biddingCompanies.id, + companyId: biddingCompanies.companyId, + hasChemicalSubstance: biddingCompanies.hasChemicalSubstance, + vendors: { + vendorCode: vendors.vendorCode, + vendorName: vendors.vendorName + } + }) + .from(biddingCompanies) + .leftJoin(vendors, eq(biddingCompanies.companyId, vendors.id)) + .where(and( + eq(biddingCompanies.biddingId, biddingId), + isNotNull(vendors.vendorCode), + sql`${vendors.vendorCode} != ''` + )) + + if (biddingCompaniesList.length === 0) { + return { + success: true, + message: '벤더 코드가 있는 참여업체가 없습니다.', + results: [] + } + } + + // 입찰의 PR 아이템들 조회 (자재번호 있는 것만) + const prItems = await db + .select({ + materialNumber: prItemsForBidding.materialNumber + }) + .from(prItemsForBidding) + .where(and( + eq(prItemsForBidding.biddingId, biddingId), + isNotNull(prItemsForBidding.materialNumber), + sql`${prItemsForBidding.materialNumber} != ''` + )) + + if (prItems.length === 0) { + return { + success: false, + message: '조회할 자재가 없습니다.', + results: [] + } + } + + const materialNumbers = prItems.map(item => item.materialNumber!).filter(Boolean) + + // 각 참여업체에 대해 화학물질 조회 + const results: Array<{ + biddingCompanyId: number; + vendorCode: string; + vendorName: string; + success: boolean; + hasChemicalSubstance?: boolean; + message: string; + materialResults?: Array<{ materialNumber: string; hasChemicalSubstance: boolean; message: string }>; + }> = [] + + for (const biddingCompany of biddingCompaniesList) { + try { + // 이미 검사가 완료된 경우 스킵 + if (biddingCompany.hasChemicalSubstance !== null && biddingCompany.hasChemicalSubstance !== undefined) { + results.push({ + biddingCompanyId: biddingCompany.id, + vendorCode: biddingCompany.vendors!.vendorCode!, + vendorName: biddingCompany.vendors!.vendorName || '', + success: true, + hasChemicalSubstance: biddingCompany.hasChemicalSubstance, + message: '이미 검사가 완료되었습니다.' + }) + continue + } + + // 각 자재에 대해 화학물질 조회 + let hasAnyChemicalSubstance = false + const materialResults: Array<{ materialNumber: string; hasChemicalSubstance: boolean; message: string }> = [] + + for (const materialNumber of materialNumbers) { + try { + const checkResult = await checkChemicalSubstance({ + bukrs: 'H100', // 회사코드는 H100 고정 + werks: biddingWerks, + lifnr: biddingCompany.vendors!.vendorCode!, + matnr: materialNumber + }) + + if (checkResult.success) { + const itemHasChemical = checkResult.hasChemicalSubstance || false + hasAnyChemicalSubstance = hasAnyChemicalSubstance || itemHasChemical + + materialResults.push({ + materialNumber, + hasChemicalSubstance: itemHasChemical, + message: checkResult.message || '조회 성공' + }) + } else { + materialResults.push({ + materialNumber, + hasChemicalSubstance: false, + message: checkResult.message || '조회 실패' + }) + } + + // API 호출 간 지연 + await new Promise(resolve => setTimeout(resolve, 500)) + + } catch (error) { + materialResults.push({ + materialNumber, + hasChemicalSubstance: false, + message: error instanceof Error ? error.message : 'Unknown error' + }) + } + } + + // 하나라도 Y이면 true, 모두 N이면 false + const finalHasChemicalSubstance = hasAnyChemicalSubstance + + // DB에 결과 저장 + await db + .update(biddingCompanies) + .set({ + hasChemicalSubstance: finalHasChemicalSubstance, + updatedAt: new Date() + }) + .where(eq(biddingCompanies.id, biddingCompany.id)) + + results.push({ + biddingCompanyId: biddingCompany.id, + vendorCode: biddingCompany.vendors!.vendorCode!, + vendorName: biddingCompany.vendors!.vendorName || '', + success: true, + hasChemicalSubstance: finalHasChemicalSubstance, + message: `조회 완료 (${materialResults.filter(r => r.hasChemicalSubstance).length}/${materialResults.length}개 자재에 화학물질 있음)`, + materialResults + }) + + } catch (error) { + results.push({ + biddingCompanyId: biddingCompany.id, + vendorCode: biddingCompany.vendors!.vendorCode!, + vendorName: biddingCompany.vendors!.vendorName || '', + success: false, + message: error instanceof Error ? error.message : 'Unknown error' + }) + } + } + + const successCount = results.filter(r => r.success).length + const totalCount = results.length + + console.log(`입찰 ${biddingId} 화학물질 일괄 조회 완료: ${successCount}/${totalCount} 성공`) + + return { + success: true, + message: `화학물질 일괄 조회 완료: ${successCount}/${totalCount} 성공`, + results + } + + } catch (error) { + console.error(`입찰 화학물질 일괄 조회 실패 (${biddingId}):`, error) + return { + success: false, + message: error instanceof Error ? error.message : 'Unknown error', + results: [] + } + } +} diff --git a/lib/bidding/validation.ts b/lib/bidding/validation.ts index 73c2fe21..3254ae7e 100644 --- a/lib/bidding/validation.ts +++ b/lib/bidding/validation.ts @@ -99,7 +99,6 @@ export const createBiddingSchema = z.object({ submissionEndDate: z.string().optional(), - evaluationDate: z.string().optional(), // 회의 및 문서 hasSpecificationMeeting: z.boolean().default(false), @@ -220,7 +219,6 @@ export const createBiddingSchema = z.object({ submissionStartDate: z.string().optional(), submissionEndDate: z.string().optional(), - evaluationDate: z.string().optional(), hasSpecificationMeeting: z.boolean().optional(), hasPrDocument: z.boolean().optional(), diff --git a/lib/bidding/vendor/components/pr-items-pricing-table.tsx b/lib/bidding/vendor/components/pr-items-pricing-table.tsx index 7dd8384e..6910e360 100644 --- a/lib/bidding/vendor/components/pr-items-pricing-table.tsx +++ b/lib/bidding/vendor/components/pr-items-pricing-table.tsx @@ -4,7 +4,17 @@ import * as React from 'react' import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card' import { Input } from '@/components/ui/input' import { Label } from '@/components/ui/label' - +import { Button } from '@/components/ui/button' +import { Calendar } from '@/components/ui/calendar' +import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover' +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from '@/components/ui/dialog' import { Badge } from '@/components/ui/badge' import { Table, @@ -16,10 +26,12 @@ import { } from '@/components/ui/table' import { Package, - Download, - Calculator + Calculator, + CalendarIcon } from 'lucide-react' +import { format } from 'date-fns' +import { cn } from '@/lib/utils' import { formatDate } from '@/lib/utils' import { downloadFile, formatFileSize, getFileInfo } from '@/lib/file-download' import { getSpecDocumentsForPrItem } from '../../pre-quote/service' @@ -186,6 +198,8 @@ export function PrItemsPricingTable({ }: PrItemsPricingTableProps) { const [quotations, setQuotations] = React.useState<PrItemQuotation[]>([]) const [specDocuments, setSpecDocuments] = React.useState<Record<number, SpecDocument[]>>({}) + const [showBulkDateDialog, setShowBulkDateDialog] = React.useState(false) + const [bulkDeliveryDate, setBulkDeliveryDate] = React.useState<Date | undefined>(undefined) // 초기 견적 데이터 설정 및 SPEC 문서 로드 React.useEffect(() => { @@ -279,6 +293,21 @@ export function PrItemsPricingTable({ onTotalAmountChange(totalAmount) } + // 일괄 납기일 적용 + const applyBulkDeliveryDate = () => { + if (bulkDeliveryDate && quotations.length > 0) { + const formattedDate = format(bulkDeliveryDate, 'yyyy-MM-dd') + const updatedQuotations = quotations.map(q => ({ + ...q, + proposedDeliveryDate: formattedDate + })) + + setQuotations(updatedQuotations) + onQuotationsChange(updatedQuotations) + setShowBulkDateDialog(false) + setBulkDeliveryDate(undefined) + } + } // 통화 포맷팅 const formatCurrency = (amount: number) => { @@ -292,12 +321,26 @@ export function PrItemsPricingTable({ const totalAmount = quotations.reduce((sum, q) => sum + q.bidAmount, 0) return ( + <> <Card> <CardHeader> - <CardTitle className="flex items-center gap-2"> - <Package className="w-5 h-5" /> - 품목별 입찰 작성 - </CardTitle> + <div className="flex items-center justify-between"> + <CardTitle className="flex items-center gap-2"> + <Package className="w-5 h-5" /> + 품목별 입찰 작성 + </CardTitle> + {!readOnly && ( + <Button + type="button" + variant="outline" + size="sm" + onClick={() => setShowBulkDateDialog(true)} + > + <CalendarIcon className="h-4 w-4 mr-1" /> + 전체 납품예정일 설정 + </Button> + )} + </div> </CardHeader> <CardContent> <div className="space-y-4"> @@ -382,18 +425,14 @@ export function PrItemsPricingTable({ </span> ) : ( <Input - type="number" - inputMode="decimal" - min={0} - pattern="^(0|[1-9][0-9]*)(\.[0-9]+)?$" - value={quotation.bidUnitPrice === 0 ? '' : quotation.bidUnitPrice} + type="text" + inputMode="numeric" + value={quotation.bidUnitPrice === 0 ? '' : quotation.bidUnitPrice.toLocaleString()} onChange={(e) => { - let value = e.target.value - if (/^0[0-9]+/.test(value)) { - value = value.replace(/^0+/, '') - if (value === '') value = '0' - } - const numericValue = parseFloat(value) + // 콤마 제거 및 숫자만 허용 + const value = e.target.value.replace(/,/g, '').replace(/[^0-9]/g, '') + const numericValue = Number(value) + updateQuotation( item.id, 'bidUnitPrice', @@ -471,5 +510,73 @@ export function PrItemsPricingTable({ </div> </CardContent> </Card> + + {/* 일괄 납품예정일 설정 다이얼로그 */} + <Dialog open={showBulkDateDialog} onOpenChange={setShowBulkDateDialog}> + <DialogContent className="sm:max-w-md"> + <DialogHeader> + <DialogTitle>전체 납품예정일 설정</DialogTitle> + <DialogDescription> + 모든 PR 아이템에 동일한 납품예정일을 적용합니다. + </DialogDescription> + </DialogHeader> + + <div className="space-y-4"> + <div className="space-y-2"> + <Label>납품예정일 선택</Label> + <Popover> + <PopoverTrigger asChild> + <Button + variant="outline" + className={cn( + "w-full justify-start text-left font-normal", + !bulkDeliveryDate && "text-muted-foreground" + )} + > + <CalendarIcon className="mr-2 h-4 w-4" /> + {bulkDeliveryDate ? format(bulkDeliveryDate, "yyyy-MM-dd") : "날짜 선택"} + </Button> + </PopoverTrigger> + <PopoverContent className="w-auto p-0" align="start"> + <Calendar + mode="single" + selected={bulkDeliveryDate} + onSelect={setBulkDeliveryDate} + initialFocus + /> + </PopoverContent> + </Popover> + </div> + + <div className="bg-muted/50 rounded-lg p-3"> + <p className="text-sm text-muted-foreground"> + 선택된 날짜가 <strong>{prItems.length}개</strong>의 모든 PR 아이템에 적용됩니다. + 기존에 설정된 납품예정일은 모두 교체됩니다. + </p> + </div> + </div> + + <DialogFooter> + <Button + type="button" + variant="outline" + onClick={() => { + setShowBulkDateDialog(false) + setBulkDeliveryDate(undefined) + }} + > + 취소 + </Button> + <Button + type="button" + onClick={applyBulkDeliveryDate} + disabled={!bulkDeliveryDate} + > + 전체 적용 + </Button> + </DialogFooter> + </DialogContent> + </Dialog> + </> ) } diff --git a/lib/bidding/vendor/export-partners-biddings-to-excel.ts b/lib/bidding/vendor/export-partners-biddings-to-excel.ts new file mode 100644 index 00000000..e1d985fe --- /dev/null +++ b/lib/bidding/vendor/export-partners-biddings-to-excel.ts @@ -0,0 +1,275 @@ +import { type Table } from "@tanstack/react-table" +import ExcelJS from "exceljs" +import { PartnersBiddingListItem } from '../detail/service' +import { + biddingStatusLabels, + contractTypeLabels, +} from "@/db/schema" +import { formatDate } from "@/lib/utils" + +/** + * Partners 입찰 목록을 Excel로 내보내기 + * - 계약구분, 진행상태는 라벨(명칭)로 변환 + * - 입찰기간은 submissionStartDate, submissionEndDate 기준 + * - 날짜는 적절한 형식으로 변환 + */ +export async function exportPartnersBiddingsToExcel( + table: Table<PartnersBiddingListItem>, + { + filename = "협력업체입찰목록", + onlySelected = false, + }: { + filename?: string + onlySelected?: boolean + } = {} +): Promise<void> { + // 테이블에서 실제 사용 중인 leaf columns 가져오기 + const allColumns = table.getAllLeafColumns() + + // select, actions, attachments 컬럼 제외 + const columns = allColumns.filter( + (col) => !["select", "actions", "attachments"].includes(col.id) + ) + + // 헤더 매핑 (컬럼 id -> Excel 헤더명) + const headerMap: Record<string, string> = { + biddingNumber: "입찰 No.", + status: "입찰상태", + isUrgent: "긴급여부", + title: "입찰명", + isAttendingMeeting: "사양설명회", + isBiddingParticipated: "입찰 참여의사", + biddingSubmissionStatus: "입찰 제출여부", + contractType: "계약구분", + submissionStartDate: "입찰기간", + contractStartDate: "계약기간", + bidPicName: "입찰담당자", + supplyPicName: "조달담당자", + updatedAt: "최종수정일", + } + + // 헤더 행 생성 + const headerRow = columns.map((col) => { + return headerMap[col.id] || col.id + }) + + // 데이터 행 생성 + const rowModel = onlySelected + ? table.getFilteredSelectedRowModel() + : table.getRowModel() + + const dataRows = rowModel.rows.map((row) => { + const original = row.original + return columns.map((col) => { + const colId = col.id + let value: any + + // 특별 처리 필요한 컬럼들 + switch (colId) { + case "contractType": + // 계약구분: 라벨로 변환 + value = contractTypeLabels[original.contractType as keyof typeof contractTypeLabels] || original.contractType + break + + case "status": + // 입찰상태: 라벨로 변환 + value = biddingStatusLabels[original.status as keyof typeof biddingStatusLabels] || original.status + break + + case "isUrgent": + // 긴급여부: Yes/No + value = original.isUrgent ? "긴급" : "일반" + break + + case "isAttendingMeeting": + // 사양설명회: 참석/불참/미결정 + if (original.isAttendingMeeting === null) { + value = "해당없음" + } else { + value = original.isAttendingMeeting ? "참석" : "불참" + } + break + + case "isBiddingParticipated": + // 입찰 참여의사: 참여/불참/미결정 + if (original.isBiddingParticipated === null) { + value = "미결정" + } else { + value = original.isBiddingParticipated ? "참여" : "불참" + } + break + + case "biddingSubmissionStatus": + // 입찰 제출여부: 최종제출/제출/미제출 + const finalQuoteAmount = original.finalQuoteAmount + const isFinalSubmission = original.isFinalSubmission + + if (!finalQuoteAmount) { + value = "미제출" + } else if (isFinalSubmission) { + value = "최종제출" + } else { + value = "제출" + } + break + + case "submissionStartDate": + // 입찰기간: submissionStartDate, submissionEndDate 기준 + const startDate = original.submissionStartDate + const endDate = original.submissionEndDate + + if (!startDate || !endDate) { + value = "-" + } else { + const startObj = new Date(startDate) + const endObj = new Date(endDate) + + // 입력값 기반: 저장된 UTC 값을 그대로 표시 (타임존 가감 없음) + const formatValue = (d: Date) => d.toISOString().slice(0, 16).replace('T', ' ') + + value = `${formatValue(startObj)} ~ ${formatValue(endObj)}` + } + break + + // case "preQuoteDeadline": + // // 사전견적 마감일: 날짜 형식 + // if (!original.preQuoteDeadline) { + // value = "-" + // } else { + // const deadline = new Date(original.preQuoteDeadline) + // value = deadline.toISOString().slice(0, 16).replace('T', ' ') + // } + // break + + case "contractStartDate": + // 계약기간: contractStartDate, contractEndDate 기준 + const contractStart = original.contractStartDate + const contractEnd = original.contractEndDate + + if (!contractStart || !contractEnd) { + value = "-" + } else { + const startObj = new Date(contractStart) + const endObj = new Date(contractEnd) + value = `${formatDate(startObj, "KR")} ~ ${formatDate(endObj, "KR")}` + } + break + + case "bidPicName": + // 입찰담당자: bidPicName + value = original.bidPicName || "-" + break + + case "supplyPicName": + // 조달담당자: supplyPicName + value = original.supplyPicName || "-" + break + + case "updatedAt": + // 최종수정일: 날짜 시간 형식 + if (original.updatedAt) { + const updated = new Date(original.updatedAt) + value = updated.toISOString().slice(0, 16).replace('T', ' ') + } else { + value = "-" + } + break + + case "biddingNumber": + // 입찰번호: 원입찰번호 포함 + const biddingNumber = original.biddingNumber + const originalBiddingNumber = original.originalBiddingNumber + if (originalBiddingNumber) { + value = `${biddingNumber} (원: ${originalBiddingNumber})` + } else { + value = biddingNumber + } + break + + default: + // 기본값: row.getValue 사용 + value = row.getValue(colId) + + // null/undefined 처리 + if (value == null) { + value = "" + } + + // 객체인 경우 JSON 문자열로 변환 + if (typeof value === "object") { + value = JSON.stringify(value) + } + break + } + + return value + }) + }) + + // 최종 sheetData + const sheetData = [headerRow, ...dataRows] + + // ExcelJS로 파일 생성 및 다운로드 + await createAndDownloadExcel(sheetData, columns.length, filename) +} + +/** + * Excel 파일 생성 및 다운로드 + */ +async function createAndDownloadExcel( + sheetData: any[][], + columnCount: number, + filename: string +): Promise<void> { + // ExcelJS 워크북/시트 생성 + const workbook = new ExcelJS.Workbook() + const worksheet = workbook.addWorksheet("Sheet1") + + // 칼럼별 최대 길이 추적 + const maxColumnLengths = Array(columnCount).fill(0) + sheetData.forEach((row) => { + row.forEach((cellValue, colIdx) => { + const cellText = cellValue?.toString() ?? "" + if (cellText.length > maxColumnLengths[colIdx]) { + maxColumnLengths[colIdx] = cellText.length + } + }) + }) + + // 시트에 데이터 추가 + 헤더 스타일 + sheetData.forEach((arr, idx) => { + const row = worksheet.addRow(arr) + + // 헤더 스타일 적용 (첫 번째 행) + if (idx === 0) { + row.font = { bold: true } + row.alignment = { horizontal: "center" } + row.eachCell((cell) => { + cell.fill = { + type: "pattern", + pattern: "solid", + fgColor: { argb: "FFCCCCCC" }, + } + }) + } + }) + + // 칼럼 너비 자동 조정 + maxColumnLengths.forEach((len, idx) => { + // 최소 너비 10, +2 여백 + worksheet.getColumn(idx + 1).width = Math.max(len + 2, 10) + }) + + // 최종 파일 다운로드 + const buffer = await workbook.xlsx.writeBuffer() + const blob = new Blob([buffer], { + type: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", + }) + const url = URL.createObjectURL(blob) + const link = document.createElement("a") + link.href = url + link.download = `${filename}.xlsx` + link.click() + URL.revokeObjectURL(url) +} + diff --git a/lib/bidding/vendor/partners-bidding-attendance-dialog.tsx b/lib/bidding/vendor/partners-bidding-attendance-dialog.tsx index d0ef97f1..8d6cb82d 100644 --- a/lib/bidding/vendor/partners-bidding-attendance-dialog.tsx +++ b/lib/bidding/vendor/partners-bidding-attendance-dialog.tsx @@ -37,7 +37,6 @@ interface PartnersSpecificationMeetingDialogProps { title: string preQuoteDate: string | null biddingRegistrationDate: string | null - evaluationDate: string | null hasSpecificationMeeting?: boolean // 사양설명회 여부 추가 } | null biddingCompanyId: number diff --git a/lib/bidding/vendor/partners-bidding-detail.tsx b/lib/bidding/vendor/partners-bidding-detail.tsx index bf76de62..bf33cef5 100644 --- a/lib/bidding/vendor/partners-bidding-detail.tsx +++ b/lib/bidding/vendor/partners-bidding-detail.tsx @@ -75,7 +75,6 @@ interface BiddingDetail { biddingRegistrationDate: Date | string | null submissionStartDate: Date | string | null submissionEndDate: Date | string | null - evaluationDate: Date | string | null currency: string budget: number | null targetPrice: number | null @@ -869,7 +868,8 @@ export function PartnersBiddingDetail({ biddingId, companyId }: PartnersBiddingD const timeLeft = deadline.getTime() - now.getTime() const daysLeft = Math.floor(timeLeft / (1000 * 60 * 60 * 24)) const hoursLeft = Math.floor((timeLeft % (1000 * 60 * 60 * 24)) / (1000 * 60 * 60)) - const kstDeadline = new Date(deadline.getTime() + 9 * 60 * 60 * 1000).toISOString().slice(0, 16).replace('T', ' ') + // 입력값 기반: 저장된 UTC 값을 그대로 표시 + const displayDeadline = deadline.toISOString().slice(0, 16).replace('T', ' ') return ( <div className={`p-3 rounded-lg border-2 ${ @@ -884,7 +884,7 @@ export function PartnersBiddingDetail({ biddingId, companyId }: PartnersBiddingD <Calendar className="w-5 h-5" /> <span className="font-medium">제출 마감일:</span> <span className="text-lg font-semibold"> - {kstDeadline} + {displayDeadline} </span> </div> {isExpired ? ( @@ -921,17 +921,13 @@ export function PartnersBiddingDetail({ biddingId, companyId }: PartnersBiddingD <span className="font-medium">입찰서 제출기간:</span> {(() => { const start = new Date(biddingDetail.submissionStartDate!) const end = new Date(biddingDetail.submissionEndDate!) - const kstStart = new Date(start.getTime() + 9 * 60 * 60 * 1000).toISOString().slice(0, 16).replace('T', ' ') - const kstEnd = new Date(end.getTime() + 9 * 60 * 60 * 1000).toISOString().slice(0, 16).replace('T', ' ') - return `${kstStart} ~ ${kstEnd}` + const displayStart = start.toISOString().slice(0, 16).replace('T', ' ') + const displayEnd = end.toISOString().slice(0, 16).replace('T', ' ') + return `${displayStart} ~ ${displayEnd}` })()} </div> )} - {biddingDetail.evaluationDate && ( - <div> - <span className="font-medium">평가일:</span> {format(new Date(biddingDetail.evaluationDate), "yyyy-MM-dd HH:mm")} - </div> - )} + </div> </div> </CardContent> diff --git a/lib/bidding/vendor/partners-bidding-list-columns.tsx b/lib/bidding/vendor/partners-bidding-list-columns.tsx index a122e87b..09c3caad 100644 --- a/lib/bidding/vendor/partners-bidding-list-columns.tsx +++ b/lib/bidding/vendor/partners-bidding-list-columns.tsx @@ -285,7 +285,7 @@ export function getPartnersBiddingListColumns({ setRowAction }: PartnersBiddingL cell: ({ row }) => { const isAttending = row.original.isAttendingMeeting if (isAttending === null) { - return <div className="text-muted-foreground text-center">-</div> + return <div className="text-muted-foreground text-center">해당없음</div> } return isAttending ? ( <CheckCircle className="h-5 w-5 text-green-600 mx-auto" /> @@ -352,45 +352,45 @@ export function getPartnersBiddingListColumns({ setRowAction }: PartnersBiddingL const startObj = new Date(startDate) const endObj = new Date(endDate) - // UI 표시용 KST 변환 - const formatKst = (d: Date) => new Date(d.getTime() + 9 * 60 * 60 * 1000).toISOString().slice(0, 16).replace('T', ' ') + // 입력값 기반: 저장된 UTC 값 그대로 표시 (타임존 가감 없음) + const formatValue = (d: Date) => d.toISOString().slice(0, 16).replace('T', ' ') return ( <div className="text-sm"> - <div>{formatKst(startObj)}</div> + <div>{formatValue(startObj)}</div> <div className="text-muted-foreground">~</div> - <div>{formatKst(endObj)}</div> + <div>{formatValue(endObj)}</div> </div> ) }, }), // 사전견적 마감일 - columnHelper.accessor('preQuoteDeadline', { - header: '사전견적 마감일', - cell: ({ row }) => { - const deadline = row.original.preQuoteDeadline - if (!deadline) { - return <div className="text-muted-foreground">-</div> - } + // columnHelper.accessor('preQuoteDeadline', { + // header: '사전견적 마감일', + // cell: ({ row }) => { + // const deadline = row.original.preQuoteDeadline + // if (!deadline) { + // return <div className="text-muted-foreground">-</div> + // } - const now = new Date() - const deadlineDate = new Date(deadline) - const isExpired = deadlineDate < now + // const now = new Date() + // const deadlineDate = new Date(deadline) + // const isExpired = deadlineDate < now - return ( - <div className={`text-sm flex items-center gap-1 ${isExpired ? 'text-red-600' : ''}`}> - <Calendar className="w-4 h-4" /> - <span>{format(new Date(deadline), "yyyy-MM-dd HH:mm")}</span> - {isExpired && ( - <Badge variant="destructive" className="text-xs"> - 마감 - </Badge> - )} - </div> - ) - }, - }), + // return ( + // <div className={`text-sm flex items-center gap-1 ${isExpired ? 'text-red-600' : ''}`}> + // <Calendar className="w-4 h-4" /> + // <span>{format(new Date(deadline), "yyyy-MM-dd HH:mm")}</span> + // {isExpired && ( + // <Badge variant="destructive" className="text-xs"> + // 마감 + // </Badge> + // )} + // </div> + // ) + // }, + // }), // 계약기간 columnHelper.accessor('contractStartDate', { diff --git a/lib/bidding/vendor/partners-bidding-list.tsx b/lib/bidding/vendor/partners-bidding-list.tsx index 0f68ed68..f1cb0bdc 100644 --- a/lib/bidding/vendor/partners-bidding-list.tsx +++ b/lib/bidding/vendor/partners-bidding-list.tsx @@ -181,7 +181,6 @@ export function PartnersBiddingList({ promises }: PartnersBiddingListProps) { title: rowAction.row.original.title, preQuoteDate: null, biddingRegistrationDate: rowAction.row.original.submissionStartDate?.toISOString() || null, - evaluationDate: null, hasSpecificationMeeting: rowAction.row.original.hasSpecificationMeeting || false, } : null} biddingCompanyId={rowAction?.row.original?.biddingCompanyId || 0} diff --git a/lib/bidding/vendor/partners-bidding-toolbar-actions.tsx b/lib/bidding/vendor/partners-bidding-toolbar-actions.tsx index 87b1367e..9a2f026c 100644 --- a/lib/bidding/vendor/partners-bidding-toolbar-actions.tsx +++ b/lib/bidding/vendor/partners-bidding-toolbar-actions.tsx @@ -2,10 +2,12 @@ import * as React from "react" import { type Table } from "@tanstack/react-table" -import { Users} from "lucide-react" +import { Users, FileSpreadsheet } from "lucide-react" +import { toast } from "sonner" import { Button } from "@/components/ui/button" import { PartnersBiddingListItem } from '../detail/service' +import { exportPartnersBiddingsToExcel } from './export-partners-biddings-to-excel' interface PartnersBiddingToolbarActionsProps { table: Table<PartnersBiddingListItem> @@ -20,6 +22,8 @@ export function PartnersBiddingToolbarActions({ const selectedRows = table.getFilteredSelectedRowModel().rows const selectedBidding = selectedRows.length === 1 ? selectedRows[0].original : null + const [isExporting, setIsExporting] = React.useState(false) + const handleSpecificationMeetingClick = () => { if (selectedBidding && setRowAction) { setRowAction({ @@ -29,8 +33,36 @@ export function PartnersBiddingToolbarActions({ } } + // Excel 내보내기 핸들러 + const handleExport = React.useCallback(async () => { + try { + setIsExporting(true) + await exportPartnersBiddingsToExcel(table, { + filename: "협력업체입찰목록", + onlySelected: false, + }) + toast.success("Excel 파일이 다운로드되었습니다.") + } catch (error) { + console.error("Excel export error:", error) + toast.error("Excel 내보내기 중 오류가 발생했습니다.") + } finally { + setIsExporting(false) + } + }, [table]) + return ( <div className="flex items-center gap-2"> + {/* Excel 내보내기 버튼 */} + <Button + variant="outline" + size="sm" + onClick={handleExport} + disabled={isExporting} + className="gap-2" + > + <FileSpreadsheet className="size-4" aria-hidden="true" /> + <span className="hidden sm:inline">{isExporting ? "내보내는 중..." : "Excel 내보내기"}</span> + </Button> <Button variant="outline" size="sm" |
