summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--lib/bidding/detail/service.ts254
-rw-r--r--lib/bidding/pre-quote/service.ts44
-rw-r--r--lib/bidding/selection/actions.ts118
-rw-r--r--lib/bidding/selection/bidding-info-card.tsx14
-rw-r--r--lib/bidding/selection/bidding-item-table.tsx71
-rw-r--r--lib/bidding/service.ts23
-rw-r--r--lib/general-contracts/detail/general-contract-items-table.tsx117
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}