From de81b281d9a3c2883a623c3f25e2889ec10a091b Mon Sep 17 00:00:00 2001 From: dujinkim Date: Sun, 14 Sep 2025 07:24:55 +0000 Subject: (최겸) 입찰 벤더별/품목별/수량, 중량별 제출가 비교 dialog 추가 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/bidding/detail/service.ts | 101 ++++++ .../bidding-detail-vendor-toolbar-actions.tsx | 26 +- .../detail/table/bidding-vendor-prices-dialog.tsx | 347 +++++++++++++++++++++ 3 files changed, 473 insertions(+), 1 deletion(-) create mode 100644 lib/bidding/detail/table/bidding-vendor-prices-dialog.tsx (limited to 'lib') diff --git a/lib/bidding/detail/service.ts b/lib/bidding/detail/service.ts index 92be2eee..df8427da 100644 --- a/lib/bidding/detail/service.ts +++ b/lib/bidding/detail/service.ts @@ -2529,3 +2529,104 @@ 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`'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` // 입찰가를 제출한 업체만 + )) + + console.log(`Found ${vendorPrices.length} vendors for bidding ${biddingId}`) + + const result: any[] = [] + + 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) + // 벤더 처리 중 에러가 발생해도 다른 벤더들은 계속 처리 + } + } + + return result + } catch (error) { + console.error('Failed to get vendor prices for bidding:', error) + return [] + } + }, + [`bidding-vendor-prices-${biddingId}`], + { + tags: [`bidding-${biddingId}`, 'quotation-vendors', 'pr-items'] + } + )() +} 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 4e9fc58d..484b1b1e 100644 --- a/lib/bidding/detail/table/bidding-detail-vendor-toolbar-actions.tsx +++ b/lib/bidding/detail/table/bidding-detail-vendor-toolbar-actions.tsx @@ -5,10 +5,11 @@ import { type Table } from "@tanstack/react-table" import { useRouter } from "next/navigation" import { useTransition } from "react" import { Button } from "@/components/ui/button" -import { Plus, Send, RotateCcw, XCircle, Trophy, FileText } from "lucide-react" +import { Plus, Send, RotateCcw, XCircle, Trophy, FileText, DollarSign } from "lucide-react" import { QuotationVendor, registerBidding, markAsDisposal, createRebidding, awardBidding } from "@/lib/bidding/detail/service" import { BiddingDetailVendorCreateDialog } from "./bidding-detail-vendor-create-dialog" import { BiddingDocumentUploadDialog } from "./bidding-document-upload-dialog" +import { BiddingVendorPricesDialog } from "./bidding-vendor-prices-dialog" import { Bidding } from "@/db/schema" import { useToast } from "@/hooks/use-toast" @@ -38,6 +39,7 @@ export function BiddingDetailVendorToolbarActions({ const [isPending, startTransition] = useTransition() const [isCreateDialogOpen, setIsCreateDialogOpen] = React.useState(false) const [isDocumentDialogOpen, setIsDocumentDialogOpen] = React.useState(false) + const [isPricesDialogOpen, setIsPricesDialogOpen] = React.useState(false) const handleCreateVendor = () => { setIsCreateDialogOpen(true) @@ -47,6 +49,10 @@ export function BiddingDetailVendorToolbarActions({ setIsDocumentDialogOpen(true) } + const handleViewVendorPrices = () => { + setIsPricesDialogOpen(true) + } + const handleRegister = () => { startTransition(async () => { const result = await registerBidding(bidding.id, userId) @@ -191,6 +197,14 @@ export function BiddingDetailVendorToolbarActions({ 입찰문서 등록 + + + ) } diff --git a/lib/bidding/detail/table/bidding-vendor-prices-dialog.tsx b/lib/bidding/detail/table/bidding-vendor-prices-dialog.tsx new file mode 100644 index 00000000..fb54eaba --- /dev/null +++ b/lib/bidding/detail/table/bidding-vendor-prices-dialog.tsx @@ -0,0 +1,347 @@ +'use client' + +import * as React from 'react' +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, +} from '@/components/ui/dialog' +import { Button } from '@/components/ui/button' +import { Badge } from '@/components/ui/badge' +import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card' +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from '@/components/ui/table' +import { + DollarSign, + Package, + Scale, + Building, + TrendingDown, + TrendingUp +} from 'lucide-react' +import { useToast } from '@/hooks/use-toast' +import { useTransition } from 'react' +import { getVendorPricesForBidding } from '../service' + +interface VendorPrice { + companyId: number + companyName: string + biddingCompanyId: number + totalAmount: number + currency: string + itemPrices: Array<{ + prItemId: number + itemName: string + quantity: number + quantityUnit: string + weight: number | null + weightUnit: string | null + unitPrice: number + amount: number + proposedDeliveryDate?: string + }> +} + +interface BiddingVendorPricesDialogProps { + open: boolean + onOpenChange: (open: boolean) => void + biddingId: number + biddingTitle: string + budget?: number | null + targetPrice?: number | null + currency?: string +} + +export function BiddingVendorPricesDialog({ + open, + onOpenChange, + biddingId, + biddingTitle, + budget, + targetPrice, + currency = 'KRW' +}: BiddingVendorPricesDialogProps) { + const { toast } = useToast() + const [isPending, startTransition] = useTransition() + const [vendorPrices, setVendorPrices] = React.useState([]) + const [isLoading, setIsLoading] = React.useState(false) + const [viewMode, setViewMode] = React.useState<'quantity' | 'weight'>('quantity') + + // 다이얼로그가 열릴 때 데이터 로드 + React.useEffect(() => { + if (open) { + loadVendorPrices() + } + }, [open, biddingId]) + + const loadVendorPrices = async () => { + setIsLoading(true) + try { + const data = await getVendorPricesForBidding(biddingId) + setVendorPrices(data) + } catch (error) { + console.error('Failed to load vendor prices:', error) + toast({ + title: '오류', + description: '입찰가 정보를 불러오는데 실패했습니다.', + variant: 'destructive', + }) + } finally { + setIsLoading(false) + } + } + + // 금액 포맷팅 + const formatCurrency = (amount: number) => { + return new Intl.NumberFormat('ko-KR', { + style: 'currency', + currency: currency, + minimumFractionDigits: 0, + maximumFractionDigits: 0, + }).format(amount) + } + + // 수량/중량 포맷팅 + const formatQuantity = (quantity: number, unit: string) => { + return `${quantity.toLocaleString()} ${unit}` + } + + const formatWeight = (weight: number | null, unit: string | null) => { + if (!weight || !unit) return '-' + return `${weight.toLocaleString()} ${unit}` + } + + // 최저가 계산 + const getLowestPrice = (itemPrices: VendorPrice['itemPrices'], field: 'quantity' | 'weight') => { + const validPrices = itemPrices.filter(item => { + if (field === 'quantity') return item.quantity > 0 + return item.weight && item.weight > 0 + }) + + if (validPrices.length === 0) return null + + const prices = validPrices.map(item => item.unitPrice) + return Math.min(...prices) + } + + // 최고가 계산 + const getHighestPrice = (itemPrices: VendorPrice['itemPrices'], field: 'quantity' | 'weight') => { + const validPrices = itemPrices.filter(item => { + if (field === 'quantity') return item.quantity > 0 + return item.weight && item.weight > 0 + }) + + if (validPrices.length === 0) return null + + const prices = validPrices.map(item => item.unitPrice) + return Math.max(...prices) + } + + return ( + + + + + + 입찰가 비교 분석 + + {biddingTitle} + + + + 협력업체별 품목별 입찰가 정보를 비교하여 최적의 낙찰 대상을 선정할 수 있습니다. + + + +
+ {/* 상단 요약 정보 */} +
+ + + + + 예산 + + + +
+ {budget ? formatCurrency(budget) : '-'} +
+
+
+ + + + + + 내정가 + + + +
+ {targetPrice ? formatCurrency(targetPrice) : '-'} +
+
+
+ + + + + + 참여 업체 수 + + + +
+ {vendorPrices.length}개사 +
+
+
+
+ + {/* 뷰 모드 토글 */} +
+ 보기 방식: +
+ + +
+
+ + {isLoading ? ( +
+
+
+

입찰가 정보를 불러오는 중...

+
+
+ ) : vendorPrices.length > 0 ? ( +
+ {vendorPrices.map((vendor) => ( + + + +
+ + {vendor.companyName} +
+
+
+ {formatCurrency(vendor.totalAmount)} +
+
+ 총 입찰금액 +
+
+
+
+ + + + + 품목명 + + {viewMode === 'quantity' ? '수량' : '중량'} + + 단가 + 금액 + 가격대 + 납기일 + + + + {vendor.itemPrices + .filter(item => { + if (viewMode === 'quantity') return item.quantity > 0 + return item.weight && item.weight > 0 + }) + .map((item, index) => { + const lowestPrice = getLowestPrice(vendor.itemPrices, viewMode) + const highestPrice = getHighestPrice(vendor.itemPrices, viewMode) + const isLowest = item.unitPrice === lowestPrice + const isHighest = item.unitPrice === highestPrice + + return ( + + + {item.itemName} + + + {viewMode === 'quantity' + ? formatQuantity(item.quantity, item.quantityUnit) + : formatWeight(item.weight, item.weightUnit) // totalWeight를 weight로 매핑했으므로 사용 가능 + } + + + {formatCurrency(item.unitPrice)} + + + {formatCurrency(item.amount)} + + +
+ {isLowest && ( + + + 최저 + + )} + {isHighest && ( + + + 최고 + + )} +
+
+ + {item.proposedDeliveryDate ? + new Date(item.proposedDeliveryDate).toLocaleDateString('ko-KR') : + '-' + } + +
+ ) + })} +
+
+
+
+ ))} +
+ ) : ( +
+ +

입찰가 정보가 없습니다

+

협력업체들이 아직 입찰가를 제출하지 않았습니다.

+
+ )} +
+
+
+ ) +} -- cgit v1.2.3