diff options
Diffstat (limited to 'lib/bidding')
| -rw-r--r-- | lib/bidding/detail/service.ts | 101 | ||||
| -rw-r--r-- | lib/bidding/detail/table/bidding-detail-vendor-toolbar-actions.tsx | 26 | ||||
| -rw-r--r-- | lib/bidding/detail/table/bidding-vendor-prices-dialog.tsx | 347 |
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> + ) +} |
