diff options
| author | dujinkim <dujin.kim@dtsolution.co.kr> | 2025-09-14 07:24:55 +0000 |
|---|---|---|
| committer | dujinkim <dujin.kim@dtsolution.co.kr> | 2025-09-14 07:24:55 +0000 |
| commit | de81b281d9a3c2883a623c3f25e2889ec10a091b (patch) | |
| tree | a565ed9336bc6ca26094e0997aed4171ac766c35 /lib/bidding/detail/table | |
| parent | 058b32e0e5ab5bc6fd02fe57b3dde6e934f91040 (diff) | |
(최겸) 입찰 벤더별/품목별/수량, 중량별 제출가 비교 dialog 추가
Diffstat (limited to 'lib/bidding/detail/table')
| -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 |
2 files changed, 372 insertions, 1 deletions
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> + ) +} |
