diff options
| author | dujinkim <dujin.kim@dtsolution.co.kr> | 2025-12-04 12:42:23 +0000 |
|---|---|---|
| committer | dujinkim <dujin.kim@dtsolution.co.kr> | 2025-12-04 12:42:23 +0000 |
| commit | d1f1768611a73541f5d63b6735f64d194466825b (patch) | |
| tree | 44717ee4a692070fd4a9b3dd0922df34358e0598 | |
| parent | e5b36fa6a1b12446883f51fc5e7cd56d8df8d8f5 (diff) | |
(최겸) 구매 입찰 견적 히스토리, 응찰품목조회 table 개발
| -rw-r--r-- | lib/bidding/detail/service.ts | 254 | ||||
| -rw-r--r-- | lib/bidding/pre-quote/service.ts | 44 | ||||
| -rw-r--r-- | lib/bidding/selection/actions.ts | 118 | ||||
| -rw-r--r-- | lib/bidding/selection/bidding-info-card.tsx | 14 | ||||
| -rw-r--r-- | lib/bidding/selection/bidding-item-table.tsx | 71 | ||||
| -rw-r--r-- | lib/bidding/service.ts | 23 | ||||
| -rw-r--r-- | lib/general-contracts/detail/general-contract-items-table.tsx | 117 |
7 files changed, 324 insertions, 317 deletions
diff --git a/lib/bidding/detail/service.ts b/lib/bidding/detail/service.ts index a0aa3378..68c55fb0 100644 --- a/lib/bidding/detail/service.ts +++ b/lib/bidding/detail/service.ts @@ -30,43 +30,97 @@ 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, + // 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) + )) + .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, + 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 // 예상액 @@ -103,66 +157,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,86 +275,12 @@ export async function getAllBiddingCompanies(biddingId: number) { } } -// prItemsForBidding 테이블에서 품목 정보 조회 (캐시 미적용, always fresh) -export async function getPRItemsForBidding(biddingId: number) { - try { - const items = await db - .select() - .from(prItemsForBidding) - .where(eq(prItemsForBidding.biddingId, biddingId)) - .orderBy(prItemsForBidding.id) +// prItemsForBidding 테이블에서 품목 정보 조회 (deprecated - import from pre-quote/service) +// export async function getPRItemsForBidding(biddingId: number) { ... } - return items - } catch (error) { - console.error('Failed to get PR items for bidding:', error) - return [] - } -} +// 견적 시스템에서 협력업체 정보를 가져오는 함수 (Deprecated - integrated into getBiddingDetailData) +// export async function getQuotationVendors(biddingId: number): Promise<QuotationVendor[]> { ... } -// 견적 시스템에서 협력업체 정보를 가져오는 함수 (캐시 적용) -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)) - - 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) { @@ -1693,7 +1613,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 +1702,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, diff --git a/lib/bidding/pre-quote/service.ts b/lib/bidding/pre-quote/service.ts index 08cb0e2c..e1152abe 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
- }
+ selectFields.currency = companyPrItemBids.currency
- let query = db.select(selectFields).from(prItemsForBidding)
-
- 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 []
diff --git a/lib/bidding/selection/actions.ts b/lib/bidding/selection/actions.ts index 91550960..06dcbea1 100644 --- a/lib/bidding/selection/actions.ts +++ b/lib/bidding/selection/actions.ts @@ -237,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) @@ -256,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) - )) - - const targetPrice = item!.targetPrice ? parseFloat(item!.targetPrice.toString()) : null - const totalAmount = parseFloat(item!.finalQuoteAmount.toString()) + .innerJoin(prItemsForBidding, eq(companyPrItemBids.prItemId, prItemsForBidding.id)) + .where(eq(companyPrItemBids.biddingCompanyId, biddingCompanyData[0].id)) + + // 아이템 매핑 + 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 b363f538..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.biddingType} + {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 index c101f7e7..aa2b34ec 100644 --- a/lib/bidding/selection/bidding-item-table.tsx +++ b/lib/bidding/selection/bidding-item-table.tsx @@ -2,10 +2,7 @@ import * as React from 'react' import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card' -import { - getPRItemsForBidding, - getVendorPricesForBidding -} from '@/lib/bidding/detail/service' +import { getBiddingSelectionItemsAndPrices } from '@/lib/bidding/service' import { formatNumber } from '@/lib/utils' import { ScrollArea, ScrollBar } from '@/components/ui/scroll-area' @@ -21,26 +18,55 @@ export function BiddingItemTable({ biddingId }: BiddingItemTableProps) { const [loading, setLoading] = React.useState(true) React.useEffect(() => { + let isMounted = true + const loadData = async () => { try { setLoading(true) - const [prItems, vendorPrices] = await Promise.all([ - getPRItemsForBidding(biddingId), - getVendorPricesForBidding(biddingId) - ]) - console.log('prItems', prItems) - console.log('vendorPrices', vendorPrices) - setData({ prItems, vendorPrices }) + 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 { - setLoading(false) + 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> @@ -58,19 +84,6 @@ export function BiddingItemTable({ biddingId }: BiddingItemTableProps) { const { prItems, vendorPrices } = data - // Calculate Totals - const totalQuantity = prItems.reduce((sum, item) => sum + Number(item.quantity || 0), 0) - const totalWeight = prItems.reduce((sum, item) => sum + Number(item.totalWeight || 0), 0) - const totalTargetAmount = prItems.reduce((sum, item) => sum + Number(item.targetAmount || 0), 0) - - // Calculate Vendor Totals - const vendorTotals = vendorPrices.map(vendor => { - const total = vendor.itemPrices.reduce((sum: number, item: any) => sum + Number(item.amount || 0), 0) - return { - companyId: vendor.companyId, - totalAmount: total - } - }) return ( <Card> @@ -118,17 +131,17 @@ export function BiddingItemTable({ biddingId }: BiddingItemTableProps) { {/* 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(totalQuantity)}</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(totalWeight)}</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(totalTargetAmount)}</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 = totalTargetAmount > 0 ? (vTotal / totalTargetAmount) * 100 : 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> diff --git a/lib/bidding/service.ts b/lib/bidding/service.ts index 27dae87d..453989c1 100644 --- a/lib/bidding/service.ts +++ b/lib/bidding/service.ts @@ -41,7 +41,8 @@ import { revalidatePath } from 'next/cache' import { filterColumns } from '@/lib/filter-columns' import { GetBiddingsSchema, CreateBiddingSchema } from './validation' import { saveFile } from '../file-stroage' - +import { getVendorPricesForBidding } from './detail/service' +import { getPrItemsForBidding } from './pre-quote/service' // 사용자 이메일로 사용자 코드 조회 @@ -3906,4 +3907,22 @@ 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 + } +} diff --git a/lib/general-contracts/detail/general-contract-items-table.tsx b/lib/general-contracts/detail/general-contract-items-table.tsx index e5fc6cf2..4f74cfbb 100644 --- a/lib/general-contracts/detail/general-contract-items-table.tsx +++ b/lib/general-contracts/detail/general-contract-items-table.tsx @@ -32,6 +32,7 @@ import { MaterialGroupSelectorDialogSingle } from '@/components/common/material/ import { MaterialSearchItem } from '@/lib/material/material-group-service' import { ProcurementItemSelectorDialogSingle } from '@/components/common/selectors/procurement-item/procurement-item-selector-dialog-single' import { ProcurementSearchItem } from '@/components/common/selectors/procurement-item/procurement-item-service' +import { cn } from '@/lib/utils' interface ContractItem { id?: number @@ -43,12 +44,12 @@ interface ContractItem { materialGroupCode?: string materialGroupDescription?: string specification: string - quantity: number + quantity: number | string // number | string으로 변경하여 입력 중 포맷팅 지원 quantityUnit: string - totalWeight: number + totalWeight: number | string // number | string으로 변경하여 입력 중 포맷팅 지원 weightUnit: string contractDeliveryDate: string - contractUnitPrice: number + contractUnitPrice: number | string // number | string으로 변경하여 입력 중 포맷팅 지원 contractAmount: number contractCurrency: string isSelected?: boolean @@ -105,6 +106,34 @@ export function ContractItemsTable({ contractUnitPrice: '' }) + // 천단위 콤마 포맷팅 헬퍼 함수들 + const formatNumberWithCommas = (value: string | number | null | undefined): string => { + if (value === null || value === undefined || value === '') return '' + const str = value.toString() + const parts = str.split('.') + const integerPart = parts[0].replace(/,/g, '') + + // 정수부가 비어있거나 '-' 만 있는 경우 처리 + if (integerPart === '' || integerPart === '-') { + return str + } + + const num = parseFloat(integerPart) + if (isNaN(num)) return str + + const formattedInt = num.toLocaleString() + + if (parts.length > 1) { + return `${formattedInt}.${parts[1]}` + } + + return formattedInt + } + + const parseNumberFromCommas = (value: string): string => { + return value.replace(/,/g, '') + } + // 초기 데이터 로드 React.useEffect(() => { const loadItems = async () => { @@ -125,6 +154,8 @@ export function ContractItemsTable({ } } + // number 타입을 string으로 변환하지 않고 일단 그대로 둠 (렌더링 시 포맷팅) + // 단, 입력 중 편의를 위해 string이 들어올 수 있으므로 ContractItem 타입 변경함 return { id: item.id, projectId: item.projectId || null, @@ -174,8 +205,17 @@ export function ContractItemsTable({ // validation 체크 const errors: string[] = [] - for (let index = 0; index < localItems.length; index++) { - const item = localItems[index] + // 저장 시 number로 변환된 데이터 준비 + const itemsToSave = localItems.map(item => ({ + ...item, + quantity: parseFloat(item.quantity.toString().replace(/,/g, '')) || 0, + totalWeight: parseFloat(item.totalWeight.toString().replace(/,/g, '')) || 0, + contractUnitPrice: parseFloat(item.contractUnitPrice.toString().replace(/,/g, '')) || 0, + contractAmount: parseFloat(item.contractAmount.toString().replace(/,/g, '')) || 0, + })); + + for (let index = 0; index < itemsToSave.length; index++) { + const item = itemsToSave[index] // if (!item.itemCode) errors.push(`${index + 1}번째 품목의 품목코드`) if (!item.itemInfo) errors.push(`${index + 1}번째 품목의 Item 정보`) if (!item.quantity || item.quantity <= 0) errors.push(`${index + 1}번째 품목의 수량`) @@ -188,7 +228,7 @@ export function ContractItemsTable({ return } - await updateContractItems(contractId, localItems as any) + await updateContractItems(contractId, itemsToSave as any) toast.success('품목정보가 저장되었습니다.') } catch (error) { console.error('Error saving contract items:', error) @@ -199,9 +239,18 @@ export function ContractItemsTable({ } // 총 금액 계산 - const totalAmount = localItems.reduce((sum, item) => sum + item.contractAmount, 0) - const totalQuantity = localItems.reduce((sum, item) => sum + item.quantity, 0) - const totalUnitPrice = localItems.reduce((sum, item) => sum + item.contractUnitPrice, 0) + const totalAmount = localItems.reduce((sum, item) => { + const amount = parseFloat(item.contractAmount.toString().replace(/,/g, '')) || 0 + return sum + amount + }, 0) + const totalQuantity = localItems.reduce((sum, item) => { + const quantity = parseFloat(item.quantity.toString().replace(/,/g, '')) || 0 + return sum + quantity + }, 0) + const totalUnitPrice = localItems.reduce((sum, item) => { + const unitPrice = parseFloat(item.contractUnitPrice.toString().replace(/,/g, '')) || 0 + return sum + unitPrice + }, 0) const amountDifference = availableBudget - totalAmount const budgetRatio = availableBudget > 0 ? (totalAmount / availableBudget) * 100 : 0 @@ -213,12 +262,14 @@ export function ContractItemsTable({ // 아이템 업데이트 const updateItem = (index: number, field: keyof ContractItem, value: string | number | boolean | undefined) => { const updatedItems = [...localItems] - updatedItems[index] = { ...updatedItems[index], [field]: value } + const updatedItem = { ...updatedItems[index], [field]: value } + updatedItems[index] = updatedItem // 단가나 수량이 변경되면 금액 자동 계산 if (field === 'contractUnitPrice' || field === 'quantity') { - const item = updatedItems[index] - updatedItems[index].contractAmount = item.contractUnitPrice * item.quantity + const quantity = parseFloat(updatedItem.quantity.toString().replace(/,/g, '')) || 0 + const unitPrice = parseFloat(updatedItem.contractUnitPrice.toString().replace(/,/g, '')) || 0 + updatedItem.contractAmount = unitPrice * quantity } setLocalItems(updatedItems) @@ -326,7 +377,8 @@ export function ContractItemsTable({ if (batchInputData.contractUnitPrice) { updatedItem.contractUnitPrice = parseFloat(batchInputData.contractUnitPrice) || 0 // 단가가 변경되면 계약금액도 재계산 - updatedItem.contractAmount = updatedItem.contractUnitPrice * updatedItem.quantity + const quantity = parseFloat(updatedItem.quantity.toString().replace(/,/g, '')) || 0 + updatedItem.contractAmount = (parseFloat(batchInputData.contractUnitPrice) || 0) * quantity } return updatedItem @@ -712,14 +764,23 @@ export function ContractItemsTable({ )} </TableCell> */} <TableCell className="px-3 py-3"> + {readOnly ? ( + <span className="text-sm text-right">{item.quantity.toLocaleString()}</span> + ) : ( <Input - type="number" - value={item.quantity} - onChange={(e) => updateItem(index, 'quantity', parseFloat(e.target.value) || 0)} + type="text" + value={formatNumberWithCommas(item.quantity)} + onChange={(e) => { + const val = parseNumberFromCommas(e.target.value) + if (val === '' || /^-?\d*\.?\d*$/.test(val)) { + updateItem(index, 'quantity', val) + } + }} className="h-8 text-sm text-right" placeholder="0" - disabled={!isEnabled} + disabled={!isEnabled || isQuantityDisabled} /> + )} </TableCell> <TableCell className="px-3 py-3"> {readOnly ? ( @@ -748,9 +809,14 @@ export function ContractItemsTable({ <span className="text-sm text-right">{item.totalWeight.toLocaleString()}</span> ) : ( <Input - type="number" - value={item.totalWeight} - onChange={(e) => updateItem(index, 'totalWeight', parseFloat(e.target.value) || 0)} + type="text" + value={formatNumberWithCommas(item.totalWeight)} + onChange={(e) => { + const val = parseNumberFromCommas(e.target.value) + if (val === '' || /^-?\d*\.?\d*$/.test(val)) { + updateItem(index, 'totalWeight', val) + } + }} className="h-8 text-sm text-right" placeholder="0" disabled={!isEnabled || isQuantityDisabled} @@ -797,9 +863,14 @@ export function ContractItemsTable({ <span className="text-sm text-right">{item.contractUnitPrice.toLocaleString()}</span> ) : ( <Input - type="number" - value={item.contractUnitPrice} - onChange={(e) => updateItem(index, 'contractUnitPrice', parseFloat(e.target.value) || 0)} + type="text" + value={formatNumberWithCommas(item.contractUnitPrice)} + onChange={(e) => { + const val = parseNumberFromCommas(e.target.value) + if (val === '' || /^-?\d*\.?\d*$/.test(val)) { + updateItem(index, 'contractUnitPrice', val) + } + }} className="h-8 text-sm text-right" placeholder="0" disabled={!isEnabled} |
