summaryrefslogtreecommitdiff
path: root/lib
diff options
context:
space:
mode:
authordujinkim <dujin.kim@dtsolution.co.kr>2025-09-14 07:24:55 +0000
committerdujinkim <dujin.kim@dtsolution.co.kr>2025-09-14 07:24:55 +0000
commitde81b281d9a3c2883a623c3f25e2889ec10a091b (patch)
treea565ed9336bc6ca26094e0997aed4171ac766c35 /lib
parent058b32e0e5ab5bc6fd02fe57b3dde6e934f91040 (diff)
(최겸) 입찰 벤더별/품목별/수량, 중량별 제출가 비교 dialog 추가
Diffstat (limited to 'lib')
-rw-r--r--lib/bidding/detail/service.ts101
-rw-r--r--lib/bidding/detail/table/bidding-detail-vendor-toolbar-actions.tsx26
-rw-r--r--lib/bidding/detail/table/bidding-vendor-prices-dialog.tsx347
3 files changed, 473 insertions, 1 deletions
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<string>`'KRW'`, // 기본값 KRW
+ finalQuoteAmount: biddingCompanies.finalQuoteAmount,
+ isBiddingParticipated: biddingCompanies.isBiddingParticipated,
+ })
+ .from(biddingCompanies)
+ .leftJoin(vendors, eq(biddingCompanies.companyId, vendors.id))
+ .where(and(
+ eq(biddingCompanies.biddingId, biddingId),
+ eq(biddingCompanies.isBiddingParticipated, true), // 본입찰 참여 업체만
+ sql`${biddingCompanies.finalQuoteAmount} IS NOT NULL` // 입찰가를 제출한 업체만
+ ))
+
+ 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({
<FileText className="mr-2 h-4 w-4" />
입찰문서 등록
</Button>
+ <Button
+ variant="outline"
+ size="sm"
+ onClick={handleViewVendorPrices}
+ >
+ <DollarSign className="mr-2 h-4 w-4" />
+ 입찰가 비교
+ </Button>
</div>
<BiddingDetailVendorCreateDialog
@@ -210,6 +224,16 @@ export function BiddingDetailVendorToolbarActions({
userId={userId}
onSuccess={onSuccess}
/>
+
+ <BiddingVendorPricesDialog
+ open={isPricesDialogOpen}
+ onOpenChange={setIsPricesDialogOpen}
+ biddingId={biddingId}
+ biddingTitle={bidding.title}
+ budget={bidding.budget ? parseFloat(bidding.budget.toString()) : null}
+ targetPrice={bidding.targetPrice ? parseFloat(bidding.targetPrice.toString()) : null}
+ currency={bidding.currency}
+ />
</>
)
}
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<VendorPrice[]>([])
+ 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 (
+ <Dialog open={open} onOpenChange={onOpenChange}>
+ <DialogContent className="max-w-7xl max-h-[90vh] overflow-y-auto">
+ <DialogHeader>
+ <DialogTitle className="flex items-center gap-2">
+ <DollarSign className="w-6 h-6" />
+ <span>입찰가 비교 분석</span>
+ <Badge variant="outline" className="ml-auto">
+ {biddingTitle}
+ </Badge>
+ </DialogTitle>
+ <DialogDescription>
+ 협력업체별 품목별 입찰가 정보를 비교하여 최적의 낙찰 대상을 선정할 수 있습니다.
+ </DialogDescription>
+ </DialogHeader>
+
+ <div className="space-y-6">
+ {/* 상단 요약 정보 */}
+ <div className="grid grid-cols-1 md:grid-cols-3 gap-4">
+ <Card>
+ <CardHeader className="pb-3">
+ <CardTitle className="text-sm font-medium flex items-center gap-2">
+ <DollarSign className="w-4 h-4" />
+ 예산
+ </CardTitle>
+ </CardHeader>
+ <CardContent>
+ <div className="text-2xl font-bold text-blue-600">
+ {budget ? formatCurrency(budget) : '-'}
+ </div>
+ </CardContent>
+ </Card>
+
+ <Card>
+ <CardHeader className="pb-3">
+ <CardTitle className="text-sm font-medium flex items-center gap-2">
+ <TrendingDown className="w-4 h-4" />
+ 내정가
+ </CardTitle>
+ </CardHeader>
+ <CardContent>
+ <div className="text-2xl font-bold text-green-600">
+ {targetPrice ? formatCurrency(targetPrice) : '-'}
+ </div>
+ </CardContent>
+ </Card>
+
+ <Card>
+ <CardHeader className="pb-3">
+ <CardTitle className="text-sm font-medium flex items-center gap-2">
+ <Building className="w-4 h-4" />
+ 참여 업체 수
+ </CardTitle>
+ </CardHeader>
+ <CardContent>
+ <div className="text-2xl font-bold text-purple-600">
+ {vendorPrices.length}개사
+ </div>
+ </CardContent>
+ </Card>
+ </div>
+
+ {/* 뷰 모드 토글 */}
+ <div className="flex items-center gap-2">
+ <span className="text-sm font-medium">보기 방식:</span>
+ <div className="flex bg-muted rounded-lg p-1">
+ <Button
+ variant={viewMode === 'quantity' ? 'default' : 'ghost'}
+ size="sm"
+ onClick={() => setViewMode('quantity')}
+ className="flex items-center gap-2"
+ >
+ <Package className="w-4 h-4" />
+ 수량별
+ </Button>
+ <Button
+ variant={viewMode === 'weight' ? 'default' : 'ghost'}
+ size="sm"
+ onClick={() => setViewMode('weight')}
+ className="flex items-center gap-2"
+ >
+ <Scale className="w-4 h-4" />
+ 중량별
+ </Button>
+ </div>
+ </div>
+
+ {isLoading ? (
+ <div className="flex items-center justify-center py-12">
+ <div className="text-center">
+ <div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary mx-auto mb-4"></div>
+ <p className="text-muted-foreground">입찰가 정보를 불러오는 중...</p>
+ </div>
+ </div>
+ ) : vendorPrices.length > 0 ? (
+ <div className="space-y-4">
+ {vendorPrices.map((vendor) => (
+ <Card key={vendor.companyId}>
+ <CardHeader>
+ <CardTitle className="flex items-center justify-between">
+ <div className="flex items-center gap-2">
+ <Building className="w-5 h-5" />
+ <span>{vendor.companyName}</span>
+ </div>
+ <div className="text-right">
+ <div className="text-lg font-bold text-green-600">
+ {formatCurrency(vendor.totalAmount)}
+ </div>
+ <div className="text-xs text-muted-foreground">
+ 총 입찰금액
+ </div>
+ </div>
+ </CardTitle>
+ </CardHeader>
+ <CardContent>
+ <Table>
+ <TableHeader>
+ <TableRow>
+ <TableHead>품목명</TableHead>
+ <TableHead className="text-right">
+ {viewMode === 'quantity' ? '수량' : '중량'}
+ </TableHead>
+ <TableHead className="text-right">단가</TableHead>
+ <TableHead className="text-right">금액</TableHead>
+ <TableHead className="text-center">가격대</TableHead>
+ <TableHead>납기일</TableHead>
+ </TableRow>
+ </TableHeader>
+ <TableBody>
+ {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 (
+ <TableRow key={`${item.prItemId}-${index}`}>
+ <TableCell className="font-medium">
+ {item.itemName}
+ </TableCell>
+ <TableCell className="text-right font-mono">
+ {viewMode === 'quantity'
+ ? formatQuantity(item.quantity, item.quantityUnit)
+ : formatWeight(item.weight, item.weightUnit) // totalWeight를 weight로 매핑했으므로 사용 가능
+ }
+ </TableCell>
+ <TableCell className="text-right font-mono">
+ {formatCurrency(item.unitPrice)}
+ </TableCell>
+ <TableCell className="text-right font-mono">
+ {formatCurrency(item.amount)}
+ </TableCell>
+ <TableCell className="text-center">
+ <div className="flex justify-center">
+ {isLowest && (
+ <Badge variant="destructive" className="text-xs">
+ <TrendingDown className="w-3 h-3 mr-1" />
+ 최저
+ </Badge>
+ )}
+ {isHighest && (
+ <Badge variant="secondary" className="text-xs">
+ <TrendingUp className="w-3 h-3 mr-1" />
+ 최고
+ </Badge>
+ )}
+ </div>
+ </TableCell>
+ <TableCell>
+ {item.proposedDeliveryDate ?
+ new Date(item.proposedDeliveryDate).toLocaleDateString('ko-KR') :
+ '-'
+ }
+ </TableCell>
+ </TableRow>
+ )
+ })}
+ </TableBody>
+ </Table>
+ </CardContent>
+ </Card>
+ ))}
+ </div>
+ ) : (
+ <div className="text-center py-12 text-gray-500">
+ <DollarSign className="w-12 h-12 mx-auto mb-4 opacity-50" />
+ <p className="text-lg font-medium mb-2">입찰가 정보가 없습니다</p>
+ <p className="text-sm">협력업체들이 아직 입찰가를 제출하지 않았습니다.</p>
+ </div>
+ )}
+ </div>
+ </DialogContent>
+ </Dialog>
+ )
+}