summaryrefslogtreecommitdiff
path: root/lib/bidding/selection
diff options
context:
space:
mode:
authordujinkim <dujin.kim@dtsolution.co.kr>2025-12-04 12:42:23 +0000
committerdujinkim <dujin.kim@dtsolution.co.kr>2025-12-04 12:42:23 +0000
commitd1f1768611a73541f5d63b6735f64d194466825b (patch)
tree44717ee4a692070fd4a9b3dd0922df34358e0598 /lib/bidding/selection
parente5b36fa6a1b12446883f51fc5e7cd56d8df8d8f5 (diff)
(최겸) 구매 입찰 견적 히스토리, 응찰품목조회 table 개발
Diffstat (limited to 'lib/bidding/selection')
-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
3 files changed, 109 insertions, 94 deletions
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>