diff options
Diffstat (limited to 'lib/bidding/detail/table')
8 files changed, 402 insertions, 1124 deletions
diff --git a/lib/bidding/detail/table/bidding-detail-content.tsx b/lib/bidding/detail/table/bidding-detail-content.tsx deleted file mode 100644 index 05c7d567..00000000 --- a/lib/bidding/detail/table/bidding-detail-content.tsx +++ /dev/null @@ -1,314 +0,0 @@ -'use client' - -import * as React from 'react' -import { Bidding } from '@/db/schema' -import { QuotationDetails, QuotationVendor } from '@/lib/bidding/detail/service' - -import { BiddingDetailVendorTableContent } from './bidding-detail-vendor-table' -import { BiddingDetailItemsDialog } from './bidding-detail-items-dialog' -import { BiddingDetailTargetPriceDialog } from './bidding-detail-target-price-dialog' -import { BiddingPreQuoteItemDetailsDialog } from '../../../bidding/pre-quote/table/bidding-pre-quote-item-details-dialog' -import { getPrItemsForBidding } from '../../../bidding/pre-quote/service' -import { checkAllVendorsFinalSubmitted, performBidOpening } from '../bidding-actions' -import { useToast } from '@/hooks/use-toast' -import { useTransition } from 'react' -import { useSession } from 'next-auth/react' -import { BiddingNoticeEditor } from '@/lib/bidding/bidding-notice-editor' -import { getBiddingNotice } from '@/lib/bidding/service' -import { Button } from '@/components/ui/button' -import { Card, CardContent } from '@/components/ui/card' -import { Badge } from '@/components/ui/badge' -import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from '@/components/ui/dialog' -import { FileText, Eye, CheckCircle2, AlertCircle } from 'lucide-react' - -interface BiddingDetailContentProps { - bidding: Bidding - quotationDetails: QuotationDetails | null - quotationVendors: QuotationVendor[] - prItems: any[] -} - -export function BiddingDetailContent({ - bidding, - quotationDetails, - quotationVendors, - prItems -}: BiddingDetailContentProps) { - const { toast } = useToast() - const [isPending, startTransition] = useTransition() - const session = useSession() - - const [dialogStates, setDialogStates] = React.useState({ - items: false, - targetPrice: false, - selectionReason: false, - award: false, - biddingNotice: false - }) - - const [, setRefreshTrigger] = React.useState(0) - - // PR 아이템 다이얼로그 관련 state - const [isItemDetailsDialogOpen, setIsItemDetailsDialogOpen] = React.useState(false) - const [selectedVendorForDetails, setSelectedVendorForDetails] = React.useState<QuotationVendor | null>(null) - const [prItemsForDialog, setPrItemsForDialog] = React.useState<any[]>([]) - - // 입찰공고 관련 state - const [biddingNotice, setBiddingNotice] = React.useState<any>(null) - const [isBiddingNoticeLoading, setIsBiddingNoticeLoading] = React.useState(false) - - // 최종제출 현황 관련 state - const [finalSubmissionStatus, setFinalSubmissionStatus] = React.useState<{ - allSubmitted: boolean - totalCompanies: number - submittedCompanies: number - }>({ allSubmitted: false, totalCompanies: 0, submittedCompanies: 0 }) - const [isPerformingBidOpening, setIsPerformingBidOpening] = React.useState(false) - - const handleRefresh = React.useCallback(() => { - setRefreshTrigger(prev => prev + 1) - }, []) - - // 입찰공고 로드 함수 - const loadBiddingNotice = React.useCallback(async () => { - if (!bidding.id) return - - setIsBiddingNoticeLoading(true) - try { - const notice = await getBiddingNotice(bidding.id) - setBiddingNotice(notice) - } catch (error) { - console.error('Failed to load bidding notice:', error) - toast({ - title: '오류', - description: '입찰공고문을 불러오는데 실패했습니다.', - variant: 'destructive', - }) - } finally { - setIsBiddingNoticeLoading(false) - } - }, [bidding.id, toast]) - - const openDialog = React.useCallback((type: keyof typeof dialogStates) => { - setDialogStates(prev => ({ ...prev, [type]: true })) - }, []) - - // 최종제출 현황 로드 함수 - const loadFinalSubmissionStatus = React.useCallback(async () => { - if (!bidding.id) return - - try { - const status = await checkAllVendorsFinalSubmitted(bidding.id) - setFinalSubmissionStatus(status) - } catch (error) { - console.error('Failed to load final submission status:', error) - } - }, [bidding.id]) - - // 개찰 핸들러 - const handlePerformBidOpening = async (isEarly: boolean = false) => { - if (!session.data?.user?.id) { - toast({ - title: '권한 없음', - description: '로그인이 필요합니다.', - variant: 'destructive', - }) - return - } - - if (!finalSubmissionStatus.allSubmitted) { - toast({ - title: '개찰 불가', - description: `모든 벤더가 최종 제출해야 개찰할 수 있습니다. (${finalSubmissionStatus.submittedCompanies}/${finalSubmissionStatus.totalCompanies})`, - variant: 'destructive', - }) - return - } - - const message = isEarly ? '조기개찰을 진행하시겠습니까?' : '개찰을 진행하시겠습니까?' - if (!window.confirm(message)) { - return - } - - setIsPerformingBidOpening(true) - try { - const result = await performBidOpening(bidding.id, session.data.user.id.toString(), isEarly) - - if (result.success) { - toast({ - title: '개찰 완료', - description: result.message, - }) - // 페이지 새로고침 - window.location.reload() - } else { - toast({ - title: '개찰 실패', - description: result.error, - variant: 'destructive', - }) - } - } catch (error) { - console.error('Failed to perform bid opening:', error) - toast({ - title: '오류', - description: '개찰에 실패했습니다.', - variant: 'destructive', - }) - } finally { - setIsPerformingBidOpening(false) - } - } - - // 컴포넌트 마운트 시 입찰공고 및 최종제출 현황 로드 - React.useEffect(() => { - loadBiddingNotice() - loadFinalSubmissionStatus() - }, [loadBiddingNotice, loadFinalSubmissionStatus]) - - const closeDialog = React.useCallback((type: keyof typeof dialogStates) => { - setDialogStates(prev => ({ ...prev, [type]: false })) - }, []) - - const handleViewItemDetails = React.useCallback((vendor: QuotationVendor) => { - startTransition(async () => { - try { - // PR 아이템 정보 로드 - const prItemsData = await getPrItemsForBidding(bidding.id) - setPrItemsForDialog(prItemsData) - setSelectedVendorForDetails(vendor) - setIsItemDetailsDialogOpen(true) - } catch (error) { - console.error('Failed to load PR items:', error) - toast({ - title: '오류', - description: '품목 정보를 불러오는데 실패했습니다.', - variant: 'destructive', - }) - } - }) - }, [bidding.id, toast]) - - // 개찰 버튼 표시 여부 (입찰평가중 상태에서만) - const showBidOpeningButtons = bidding.status === 'evaluation_of_bidding' - - return ( - <div className="space-y-6"> - {/* 입찰공고 편집 버튼 */} - <div className="flex justify-between items-center"> - <div> - <h2 className="text-2xl font-bold">입찰 상세</h2> - <p className="text-muted-foreground">{bidding.title}</p> - </div> - <Dialog open={dialogStates.biddingNotice} onOpenChange={(open) => setDialogStates(prev => ({ ...prev, biddingNotice: open }))}> - <DialogTrigger asChild> - <Button variant="outline" className="gap-2"> - <FileText className="h-4 w-4" /> - 입찰공고 편집 - </Button> - </DialogTrigger> - <DialogContent className="max-w-4xl max-h-[80vh] overflow-hidden"> - <DialogHeader> - <DialogTitle>입찰공고 편집</DialogTitle> - </DialogHeader> - <div className="max-h-[60vh] overflow-y-auto"> - <BiddingNoticeEditor - initialData={biddingNotice} - biddingId={bidding.id} - onSaveSuccess={() => setDialogStates(prev => ({ ...prev, biddingNotice: false }))} - /> - </div> - </DialogContent> - </Dialog> - </div> - - {/* 최종제출 현황 및 개찰 버튼 */} - {showBidOpeningButtons && ( - <Card> - <CardContent className="pt-6"> - <div className="flex items-center justify-between"> - <div className="flex items-center gap-4"> - <div> - <div className="flex items-center gap-2 mb-1"> - {finalSubmissionStatus.allSubmitted ? ( - <CheckCircle2 className="h-5 w-5 text-green-600" /> - ) : ( - <AlertCircle className="h-5 w-5 text-yellow-600" /> - )} - <h3 className="text-lg font-semibold">최종제출 현황</h3> - </div> - <div className="flex items-center gap-2"> - <span className="text-sm text-muted-foreground"> - 최종 제출: {finalSubmissionStatus.submittedCompanies}/{finalSubmissionStatus.totalCompanies}개 업체 - </span> - {finalSubmissionStatus.allSubmitted ? ( - <Badge variant="default">모든 업체 제출 완료</Badge> - ) : ( - <Badge variant="secondary">제출 대기 중</Badge> - )} - </div> - </div> - </div> - - {/* 개찰 버튼들 */} - <div className="flex gap-2"> - <Button - onClick={() => handlePerformBidOpening(false)} - disabled={!finalSubmissionStatus.allSubmitted || isPerformingBidOpening} - variant="default" - > - <Eye className="h-4 w-4 mr-2" /> - {isPerformingBidOpening ? '처리 중...' : '개찰'} - </Button> - <Button - onClick={() => handlePerformBidOpening(true)} - disabled={!finalSubmissionStatus.allSubmitted || isPerformingBidOpening} - variant="outline" - > - <Eye className="h-4 w-4 mr-2" /> - {isPerformingBidOpening ? '처리 중...' : '조기개찰'} - </Button> - </div> - </div> - </CardContent> - </Card> - )} - - <BiddingDetailVendorTableContent - biddingId={bidding.id} - bidding={bidding} - vendors={quotationVendors} - onRefresh={handleRefresh} - onOpenTargetPriceDialog={() => openDialog('targetPrice')} - onOpenSelectionReasonDialog={() => openDialog('selectionReason')} - onViewItemDetails={handleViewItemDetails} - onEdit={undefined} - /> - - <BiddingDetailItemsDialog - open={dialogStates.items} - onOpenChange={(open) => closeDialog('items')} - prItems={prItems} - bidding={bidding} - /> - - <BiddingDetailTargetPriceDialog - open={dialogStates.targetPrice} - onOpenChange={(open) => closeDialog('targetPrice')} - quotationDetails={quotationDetails} - bidding={bidding} - onSuccess={handleRefresh} - /> - - <BiddingPreQuoteItemDetailsDialog - open={isItemDetailsDialogOpen} - onOpenChange={setIsItemDetailsDialogOpen} - biddingId={bidding.id} - biddingCompanyId={selectedVendorForDetails?.id || 0} - companyName={selectedVendorForDetails?.vendorName || ''} - prItems={prItemsForDialog} - currency={bidding.currency || 'KRW'} - /> - </div> - ) -} diff --git a/lib/bidding/detail/table/bidding-detail-items-dialog.tsx b/lib/bidding/detail/table/bidding-detail-items-dialog.tsx index 8c2ae44a..086ab67d 100644 --- a/lib/bidding/detail/table/bidding-detail-items-dialog.tsx +++ b/lib/bidding/detail/table/bidding-detail-items-dialog.tsx @@ -25,12 +25,12 @@ interface PrItem { itemName: string itemCode: string specification: string - quantity: number - unit: string - estimatedPrice: number - budget: number - deliveryDate: Date - notes: string + quantity: number | null + unit: string | null + estimatedPrice: number | null + budget: number | null + deliveryDate: Date | null + notes: string | null createdAt: Date updatedAt: Date } diff --git a/lib/bidding/detail/table/bidding-detail-target-price-dialog.tsx b/lib/bidding/detail/table/bidding-detail-target-price-dialog.tsx deleted file mode 100644 index a8f604d8..00000000 --- a/lib/bidding/detail/table/bidding-detail-target-price-dialog.tsx +++ /dev/null @@ -1,356 +0,0 @@ -'use client' - -import * as React from 'react' -import { Bidding } from '@/db/schema' -import { QuotationDetails, updateTargetPrice, calculateAndUpdateTargetPrice, getPreQuoteData } from '@/lib/bidding/detail/service' -import { - Dialog, - DialogContent, - DialogDescription, - DialogFooter, - DialogHeader, - DialogTitle, -} from '@/components/ui/dialog' -import { Button } from '@/components/ui/button' -import { Input } from '@/components/ui/input' -import { Label } from '@/components/ui/label' -import { Textarea } from '@/components/ui/textarea' -import { - Table, - TableBody, - TableCell, - TableHead, - TableHeader, - TableRow, -} from '@/components/ui/table' -import { useToast } from '@/hooks/use-toast' -import { useTransition } from 'react' - -interface BiddingDetailTargetPriceDialogProps { - open: boolean - onOpenChange: (open: boolean) => void - quotationDetails: QuotationDetails | null - bidding: Bidding - onSuccess: () => void -} - -export function BiddingDetailTargetPriceDialog({ - open, - onOpenChange, - quotationDetails, - bidding, - onSuccess -}: BiddingDetailTargetPriceDialogProps) { - const { toast } = useToast() - const [isPending, startTransition] = useTransition() - const [targetPrice, setTargetPrice] = React.useState( - bidding.targetPrice ? Number(bidding.targetPrice) : 0 - ) - const [calculationCriteria, setCalculationCriteria] = React.useState( - (bidding as any).targetPriceCalculationCriteria || '' - ) - const [preQuoteData, setPreQuoteData] = React.useState<any>(null) - const [isAutoCalculating, setIsAutoCalculating] = React.useState(false) - - // Dialog가 열릴 때 상태 초기화 및 사전견적 데이터 로드 - React.useEffect(() => { - if (open) { - setTargetPrice(bidding.targetPrice ? Number(bidding.targetPrice) : 0) - setCalculationCriteria((bidding as any).targetPriceCalculationCriteria || '') - - // 사전견적 데이터 로드 - const loadPreQuoteData = async () => { - try { - const data = await getPreQuoteData(bidding.id) - setPreQuoteData(data) - } catch (error) { - console.error('Failed to load pre-quote data:', error) - } - } - loadPreQuoteData() - } - }, [open, bidding]) - - // 자동 산정 함수 - const handleAutoCalculate = () => { - setIsAutoCalculating(true) - - startTransition(async () => { - try { - const result = await calculateAndUpdateTargetPrice( - bidding.id - ) - - if (result.success && result.data) { - setTargetPrice(result.data.targetPrice) - setCalculationCriteria(result.data.criteria) - setPreQuoteData(result.data.preQuoteData) - - toast({ - title: '성공', - description: result.message, - }) - - onSuccess() - } else { - toast({ - title: '오류', - description: result.error, - variant: 'destructive', - }) - } - } catch (error) { - toast({ - title: '오류', - description: '내정가 자동 산정에 실패했습니다.', - variant: 'destructive', - }) - } finally { - setIsAutoCalculating(false) - } - }) - } - - const handleSave = () => { - // 필수값 검증 - if (targetPrice <= 0) { - toast({ - title: '유효성 오류', - description: '내정가는 0보다 큰 값을 입력해주세요.', - variant: 'destructive', - }) - return - } - - if (!calculationCriteria.trim()) { - toast({ - title: '유효성 오류', - description: '내정가 산정 기준을 입력해주세요.', - variant: 'destructive', - }) - return - } - - startTransition(async () => { - const result = await updateTargetPrice( - bidding.id, - targetPrice, - calculationCriteria.trim() - ) - - if (result.success) { - toast({ - title: '성공', - description: result.message, - }) - onSuccess() - onOpenChange(false) - } else { - toast({ - title: '오류', - description: result.error, - variant: 'destructive', - }) - } - }) - } - - const formatCurrency = (amount: number) => { - return new Intl.NumberFormat('ko-KR', { - style: 'currency', - currency: bidding.currency || 'KRW', - }).format(amount) - } - - return ( - <Dialog open={open} onOpenChange={onOpenChange}> - <DialogContent className="sm:max-w-[800px]"> - <DialogHeader> - <DialogTitle>내정가 산정</DialogTitle> - <DialogDescription> - 입찰번호: {bidding.biddingNumber} - 견적 통계 및 내정가 설정 - </DialogDescription> - </DialogHeader> - - <div className="space-y-4"> - {/* 사전견적 리스트 */} - {preQuoteData?.quotes && preQuoteData.quotes.length > 0 && ( - <div className="mb-4"> - <h4 className="text-sm font-medium mb-2">사전견적 현황</h4> - <div className="border rounded-lg"> - <Table> - <TableHeader> - <TableRow> - <TableHead>업체명</TableHead> - <TableHead className="text-right">사전견적가</TableHead> - <TableHead className="text-right">제출일</TableHead> - </TableRow> - </TableHeader> - <TableBody> - {preQuoteData.quotes.map((quote: any) => ( - <TableRow key={quote.id}> - <TableCell className="font-medium"> - {quote.vendorName || `업체 ${quote.companyId}`} - </TableCell> - <TableCell className="text-right font-mono"> - {formatCurrency(Number(quote.preQuoteAmount))} - </TableCell> - <TableCell className="text-right text-sm text-muted-foreground"> - {quote.submittedAt - ? new Date(quote.submittedAt).toLocaleDateString('ko-KR') - : '-' - } - </TableCell> - </TableRow> - ))} - </TableBody> - </Table> - </div> - </div> - )} - - <Table> - <TableHeader> - <TableRow> - <TableHead className="w-[200px]">항목</TableHead> - <TableHead>값</TableHead> - </TableRow> - </TableHeader> - <TableBody> - {/* 사전견적 통계 정보 */} - <TableRow> - <TableCell className="font-medium">사전견적 수</TableCell> - <TableCell className="font-semibold"> - {preQuoteData?.quotationCount || 0}개 - </TableCell> - </TableRow> - {preQuoteData?.lowestQuote && ( - <TableRow> - <TableCell className="font-medium">최저 사전견적가</TableCell> - <TableCell className="font-semibold text-green-600"> - {formatCurrency(preQuoteData.lowestQuote)} - </TableCell> - </TableRow> - )} - {preQuoteData?.highestQuote && ( - <TableRow> - <TableCell className="font-medium">최고 사전견적가</TableCell> - <TableCell className="font-semibold text-blue-600"> - {formatCurrency(preQuoteData.highestQuote)} - </TableCell> - </TableRow> - )} - {preQuoteData?.averageQuote && ( - <TableRow> - <TableCell className="font-medium">평균 사전견적가</TableCell> - <TableCell className="font-semibold"> - {formatCurrency(preQuoteData.averageQuote)} - </TableCell> - </TableRow> - )} - - {/* 입찰 유형 */} - <TableRow> - <TableCell className="font-medium">입찰 유형</TableCell> - <TableCell className="font-semibold"> - {bidding.biddingType || '-'} - </TableCell> - </TableRow> - - {/* 예산 정보 */} - {bidding.budget && ( - <TableRow> - <TableCell className="font-medium">예산</TableCell> - <TableCell className="font-semibold"> - {formatCurrency(Number(bidding.budget))} - </TableCell> - </TableRow> - )} - - {/* 최종 업데이트 시간 */} - {quotationDetails?.lastUpdated && ( - <TableRow> - <TableCell className="font-medium">최종 업데이트</TableCell> - <TableCell className="text-sm text-muted-foreground"> - {new Date(quotationDetails.lastUpdated).toLocaleString('ko-KR')} - </TableCell> - </TableRow> - )} - - {/* 내정가 입력 */} - <TableRow> - <TableCell className="font-medium"> - <Label htmlFor="targetPrice" className="text-sm font-medium"> - 내정가 * - </Label> - </TableCell> - <TableCell> - <div className="space-y-2"> - <div className="flex gap-2"> - <Input - id="targetPrice" - type="number" - value={targetPrice} - onChange={(e) => setTargetPrice(Number(e.target.value))} - placeholder="내정가를 입력하세요" - className="flex-1" - /> - <Button - type="button" - variant="outline" - onClick={handleAutoCalculate} - disabled={isAutoCalculating || isPending || !preQuoteData?.quotationCount} - className="whitespace-nowrap" - > - {isAutoCalculating ? '산정 중...' : '자동 산정'} - </Button> - </div> - <div className="text-sm text-muted-foreground"> - {targetPrice > 0 ? formatCurrency(targetPrice) : ''} - </div> - {preQuoteData?.quotationCount === 0 && ( - <div className="text-xs text-orange-600"> - 사전견적 데이터가 없어 자동 산정이 불가능합니다. - </div> - )} - </div> - </TableCell> - </TableRow> - - {/* 내정가 산정 기준 입력 */} - <TableRow> - <TableCell className="font-medium align-top pt-2"> - <Label htmlFor="calculationCriteria" className="text-sm font-medium"> - 내정가 산정 기준 * - </Label> - </TableCell> - <TableCell> - <Textarea - id="calculationCriteria" - value={calculationCriteria} - onChange={(e) => setCalculationCriteria(e.target.value)} - placeholder="내정가 산정 기준을 자세히 입력해주세요. 자동 산정 시 입찰유형에 따른 기준이 자동 설정됩니다." - className="w-full min-h-[100px]" - rows={4} - /> - <div className="text-xs text-muted-foreground mt-1"> - 필수 입력 사항입니다. 내정가 산정에 대한 근거를 명확히 기재해주세요. - </div> - </TableCell> - </TableRow> - </TableBody> - </Table> - </div> - - <DialogFooter> - <Button variant="outline" onClick={() => onOpenChange(false)}> - 취소 - </Button> - <Button onClick={handleSave} disabled={isPending || isAutoCalculating}> - 저장 - </Button> - </DialogFooter> - </DialogContent> - </Dialog> - ) -} diff --git a/lib/bidding/detail/table/bidding-detail-vendor-columns.tsx b/lib/bidding/detail/table/bidding-detail-vendor-columns.tsx index 10085e55..af7d70e1 100644 --- a/lib/bidding/detail/table/bidding-detail-vendor-columns.tsx +++ b/lib/bidding/detail/table/bidding-detail-vendor-columns.tsx @@ -24,6 +24,7 @@ interface GetVendorColumnsProps { onViewItemDetails?: (vendor: QuotationVendor) => void onSendBidding?: (vendor: QuotationVendor) => void onUpdateParticipation?: (vendor: QuotationVendor, participated: boolean) => void + onViewQuotationHistory?: (vendor: QuotationVendor) => void biddingStatus?: string // 입찰 상태 정보 추가 } @@ -32,6 +33,7 @@ export function getBiddingDetailVendorColumns({ onViewItemDetails, onSendBidding, onUpdateParticipation, + onViewQuotationHistory, biddingStatus }: GetVendorColumnsProps): ColumnDef<QuotationVendor>[] { return [ @@ -124,7 +126,7 @@ export function getBiddingDetailVendorColumns({ } return ( <Badge variant={participated ? 'default' : 'destructive'}> - {participated ? '응찰' : '미응찰'} + {participated ? '응찰' : '응찰포기'} </Badge> ) }, @@ -198,7 +200,7 @@ export function getBiddingDetailVendorColumns({ 응찰 설정 </DropdownMenuItem> <DropdownMenuItem onClick={() => onUpdateParticipation(vendor, false)}> - 미응찰 설정 + 응찰포기 설정 </DropdownMenuItem> </> )} @@ -212,7 +214,17 @@ export function getBiddingDetailVendorColumns({ </DropdownMenuItem> </> )} - + + {/* 견적 히스토리 (응찰한 업체만) */} + {vendor.isBiddingParticipated === true && onViewQuotationHistory && ( + <> + <DropdownMenuSeparator /> + <DropdownMenuItem onClick={() => onViewQuotationHistory(vendor)}> + 견적 히스토리 + </DropdownMenuItem> + </> + )} + </DropdownMenuContent> </DropdownMenu> ) diff --git a/lib/bidding/detail/table/bidding-detail-vendor-table.tsx b/lib/bidding/detail/table/bidding-detail-vendor-table.tsx index f2b05d4e..315c2aac 100644 --- a/lib/bidding/detail/table/bidding-detail-vendor-table.tsx +++ b/lib/bidding/detail/table/bidding-detail-vendor-table.tsx @@ -13,6 +13,7 @@ import { getBiddingDetailVendorColumns } from './bidding-detail-vendor-columns' import { QuotationVendor, getPriceAdjustmentFormByBiddingCompanyId } from '@/lib/bidding/detail/service' import { Bidding } from '@/db/schema' import { PriceAdjustmentDialog } from '@/components/bidding/price-adjustment-dialog' +import { QuotationHistoryDialog } from './quotation-history-dialog' import { useToast } from '@/hooks/use-toast' interface BiddingDetailVendorTableContentProps { @@ -20,10 +21,10 @@ interface BiddingDetailVendorTableContentProps { bidding: Bidding vendors: QuotationVendor[] onRefresh: () => void - onOpenTargetPriceDialog: () => void onOpenSelectionReasonDialog: () => void onEdit?: (vendor: QuotationVendor) => void onViewItemDetails?: (vendor: QuotationVendor) => void + onViewQuotationHistory?: (vendor: QuotationVendor) => void } const filterFields: DataTableFilterField<QuotationVendor>[] = [ @@ -82,9 +83,9 @@ export function BiddingDetailVendorTableContent({ bidding, vendors, onRefresh, - onOpenTargetPriceDialog, onEdit, - onViewItemDetails + onViewItemDetails, + onViewQuotationHistory }: BiddingDetailVendorTableContentProps) { const { data: session } = useSession() const { toast } = useToast() @@ -96,6 +97,8 @@ export function BiddingDetailVendorTableContent({ const [isAwardDialogOpen, setIsAwardDialogOpen] = React.useState(false) const [priceAdjustmentData, setPriceAdjustmentData] = React.useState<any>(null) const [isPriceAdjustmentDialogOpen, setIsPriceAdjustmentDialogOpen] = React.useState(false) + const [quotationHistoryData, setQuotationHistoryData] = React.useState<any>(null) + const [isQuotationHistoryDialogOpen, setIsQuotationHistoryDialogOpen] = React.useState(false) const handleEdit = (vendor: QuotationVendor) => { setSelectedVendor(vendor) @@ -126,14 +129,46 @@ export function BiddingDetailVendorTableContent({ } } + const handleViewQuotationHistory = async (vendor: QuotationVendor) => { + try { + const { getQuotationHistory } = await import('@/lib/bidding/selection/actions') + const result = await getQuotationHistory(biddingId, vendor.vendorId) + + if (result.success) { + setQuotationHistoryData({ + vendorName: vendor.vendorName, + history: result.data.history, + biddingCurrency: bidding.currency || 'KRW', + targetPrice: bidding.targetPrice ? parseFloat(bidding.targetPrice.toString()) : undefined + }) + setSelectedVendor(vendor) + setIsQuotationHistoryDialogOpen(true) + } else { + toast({ + title: '오류', + description: result.error, + variant: 'destructive', + }) + } + } catch (error) { + console.error('Failed to load quotation history:', error) + toast({ + title: '오류', + description: '견적 히스토리를 불러오는데 실패했습니다.', + variant: 'destructive', + }) + } + } + const columns = React.useMemo( () => getBiddingDetailVendorColumns({ onEdit: onEdit || handleEdit, onViewPriceAdjustment: handleViewPriceAdjustment, onViewItemDetails: onViewItemDetails, + onViewQuotationHistory: onViewQuotationHistory || handleViewQuotationHistory, biddingStatus: bidding.status }), - [onEdit, handleEdit, handleViewPriceAdjustment, onViewItemDetails, bidding.status] + [onEdit, handleEdit, handleViewPriceAdjustment, onViewItemDetails, onViewQuotationHistory, handleViewQuotationHistory, bidding.status] ) const { table } = useDataTable({ @@ -163,7 +198,6 @@ export function BiddingDetailVendorTableContent({ biddingId={biddingId} bidding={bidding} userId={userId} - onOpenTargetPriceDialog={onOpenTargetPriceDialog} onOpenAwardDialog={() => setIsAwardDialogOpen(true)} onSuccess={onRefresh} /> @@ -192,6 +226,15 @@ export function BiddingDetailVendorTableContent({ data={priceAdjustmentData} vendorName={selectedVendor?.vendorName || ''} /> + + <QuotationHistoryDialog + open={isQuotationHistoryDialogOpen} + onOpenChange={setIsQuotationHistoryDialogOpen} + vendorName={quotationHistoryData?.vendorName || ''} + history={quotationHistoryData?.history || []} + biddingCurrency={quotationHistoryData?.biddingCurrency || 'KRW'} + targetPrice={quotationHistoryData?.targetPrice} + /> </> ) } 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 4d987739..e3db8861 100644 --- a/lib/bidding/detail/table/bidding-detail-vendor-toolbar-actions.tsx +++ b/lib/bidding/detail/table/bidding-detail-vendor-toolbar-actions.tsx @@ -4,22 +4,20 @@ import * as React from "react" import { useRouter } from "next/navigation" import { useTransition } from "react" import { Button } from "@/components/ui/button" -import { Plus, Send, RotateCcw, XCircle, Trophy, FileText, DollarSign } from "lucide-react" +import { Plus, Send, RotateCcw, XCircle, Trophy, FileText, DollarSign, RotateCw } from "lucide-react" import { registerBidding, markAsDisposal, createRebidding } from "@/lib/bidding/detail/service" import { sendBiddingBasicContracts, getSelectedVendorsForBidding } from "@/lib/bidding/pre-quote/service" +import { increaseRoundOrRebid } from "@/lib/bidding/service" import { BiddingDetailVendorCreateDialog } from "../../../../components/bidding/manage/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" -import { BiddingInvitationDialog } from "./bidding-invitation-dialog" interface BiddingDetailVendorToolbarActionsProps { biddingId: number bidding: Bidding userId: string - onOpenTargetPriceDialog: () => void onOpenAwardDialog: () => void onSuccess: () => void } @@ -28,7 +26,6 @@ export function BiddingDetailVendorToolbarActions({ biddingId, bidding, userId, - onOpenTargetPriceDialog, onOpenAwardDialog, onSuccess }: BiddingDetailVendorToolbarActionsProps) { @@ -75,52 +72,52 @@ export function BiddingDetailVendorToolbarActions({ setIsBiddingInvitationDialogOpen(true) } - const handleBiddingInvitationSend = async (data: any) => { - try { - // 1. 기본계약 발송 - const contractResult = await sendBiddingBasicContracts( - biddingId, - data.vendors, - data.generatedPdfs, - data.message - ) - - if (!contractResult.success) { - toast({ - title: '기본계약 발송 실패', - description: contractResult.error, - variant: 'destructive', - }) - return - } - - // 2. 입찰 등록 진행 - const registerResult = await registerBidding(bidding.id, userId) - - if (registerResult.success) { - toast({ - title: '본입찰 초대 완료', - description: '기본계약 발송 및 본입찰 초대가 완료되었습니다.', - }) - setIsBiddingInvitationDialogOpen(false) - router.refresh() - onSuccess() - } else { - toast({ - title: '오류', - description: registerResult.error, - variant: 'destructive', - }) - } - } catch (error) { - console.error('본입찰 초대 실패:', error) - toast({ - title: '오류', - description: '본입찰 초대에 실패했습니다.', - variant: 'destructive', - }) - } - } + // const handleBiddingInvitationSend = async (data: any) => { + // try { + // // 1. 기본계약 발송 + // const contractResult = await sendBiddingBasicContracts( + // biddingId, + // data.vendors, + // data.generatedPdfs, + // data.message + // ) + + // if (!contractResult.success) { + // toast({ + // title: '기본계약 발송 실패', + // description: contractResult.error, + // variant: 'destructive', + // }) + // return + // } + + // // 2. 입찰 등록 진행 + // const registerResult = await registerBidding(bidding.id, userId) + + // if (registerResult.success) { + // toast({ + // title: '본입찰 초대 완료', + // description: '기본계약 발송 및 본입찰 초대가 완료되었습니다.', + // }) + // setIsBiddingInvitationDialogOpen(false) + // router.refresh() + // onSuccess() + // } else { + // toast({ + // title: '오류', + // description: registerResult.error, + // variant: 'destructive', + // }) + // } + // } catch (error) { + // console.error('본입찰 초대 실패:', error) + // toast({ + // title: '오류', + // description: '본입찰 초대에 실패했습니다.', + // variant: 'destructive', + // }) + // } + // } // 선정된 업체들 조회 (서버 액션 함수 사용) const getSelectedVendors = async () => { @@ -158,21 +155,21 @@ export function BiddingDetailVendorToolbarActions({ }) } - const handleCreateRebidding = () => { + const handleRoundIncrease = () => { startTransition(async () => { - const result = await createRebidding(bidding.id, userId) + const result = await increaseRoundOrRebid(bidding.id, userId, 'round_increase') if (result.success) { toast({ - title: result.message, + title: "성공", description: result.message, }) router.refresh() onSuccess() } else { toast({ - title: result.error, - description: result.error, + title: "오류", + description: result.error || "차수증가 중 오류가 발생했습니다.", variant: 'destructive', }) } @@ -183,80 +180,47 @@ export function BiddingDetailVendorToolbarActions({ <> <div className="flex items-center gap-2"> {/* 상태별 액션 버튼 */} - {bidding.status !== 'bidding_closed' && bidding.status !== 'vendor_selected' && ( - <> - <Button - variant="default" - size="sm" - onClick={handleRegister} - disabled={isPending} - > - {/* 입찰등록 시점 재정의 필요*/} - <Send className="mr-2 h-4 w-4" /> - 입찰 등록 - </Button> - <Button - variant="destructive" - size="sm" - onClick={handleMarkAsDisposal} - disabled={isPending} - > - <XCircle className="mr-2 h-4 w-4" /> - 유찰 - </Button> - <Button - variant="default" - size="sm" - onClick={onOpenAwardDialog} - disabled={isPending} - > - <Trophy className="mr-2 h-4 w-4" /> - 낙찰 - </Button> - - - {bidding.status === 'bidding_disposal' && ( + {/* 차수증가: 입찰공고 또는 입찰 진행중 상태 */} + {(bidding.status === 'bidding_generated' || bidding.status === 'bidding_opened') && ( <Button variant="outline" size="sm" - onClick={handleCreateRebidding} + onClick={handleRoundIncrease} disabled={isPending} > - <RotateCcw className="mr-2 h-4 w-4" /> - 재입찰 + <RotateCw className="mr-2 h-4 w-4" /> + 차수증가 </Button> )} - {/* 구분선 */} - {(bidding.status === 'bidding_generated' || - bidding.status === 'bidding_disposal') && ( - <div className="h-4 w-px bg-border mx-1" /> - )} - - {/* 공통 관리 버튼들 */} - {/* <Button - variant="outline" - size="sm" - onClick={onOpenItemsDialog} - > - 품목 정보 - </Button> */} - + {/* 유찰/낙찰: 입찰 진행중 상태에서만 */} + {bidding.status === 'bidding_opened' && ( + <> <Button - variant="outline" + variant="destructive" size="sm" - onClick={onOpenTargetPriceDialog} + onClick={handleMarkAsDisposal} + disabled={isPending} > - 내정가 산정 + <XCircle className="mr-2 h-4 w-4" /> + 유찰 </Button> <Button - variant="outline" + variant="default" size="sm" - onClick={handleCreateVendor} + onClick={onOpenAwardDialog} + disabled={isPending} > - <Plus className="mr-2 h-4 w-4" /> - 업체 추가 + <Trophy className="mr-2 h-4 w-4" /> + 낙찰 </Button> + </> + )} + {/* 구분선 */} + {(bidding.status === 'bidding_generated' || + bidding.status === 'bidding_disposal') && ( + <div className="h-4 w-px bg-border mx-1" /> + )} <Button variant="outline" size="sm" @@ -265,16 +229,7 @@ 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 @@ -295,25 +250,6 @@ export function BiddingDetailVendorToolbarActions({ 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 || ''} - /> - - <BiddingInvitationDialog - open={isBiddingInvitationDialogOpen} - onOpenChange={setIsBiddingInvitationDialogOpen} - vendors={selectedVendors} - biddingId={biddingId} - biddingTitle={bidding.title || ''} - projectName={bidding.projectName || ''} - onSend={handleBiddingInvitationSend} - /> </> ) } diff --git a/lib/bidding/detail/table/bidding-vendor-prices-dialog.tsx b/lib/bidding/detail/table/bidding-vendor-prices-dialog.tsx deleted file mode 100644 index dfcef812..00000000 --- a/lib/bidding/detail/table/bidding-vendor-prices-dialog.tsx +++ /dev/null @@ -1,297 +0,0 @@ -'use client' - -import * as React from 'react' -import { - Dialog, - DialogContent, - DialogDescription, - DialogHeader, - DialogTitle, -} from '@/components/ui/dialog' -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, - Building, - TrendingDown, - TrendingUp -} from 'lucide-react' -import { useToast } from '@/hooks/use-toast' -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 - 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 [vendorPrices, setVendorPrices] = React.useState<VendorPrice[]>([]) - const [isLoading, setIsLoading] = React.useState(false) - - const loadVendorPrices = React.useCallback(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) - } - }, [biddingId, toast]) - - // 다이얼로그가 열릴 때 데이터 로드 - React.useEffect(() => { - if (open) { - loadVendorPrices() - } - }, [open, loadVendorPrices]) - - - // 금액 포맷팅 - 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 getLowestPrice = (itemPrices: VendorPrice['itemPrices']) => { - const validPrices = itemPrices.filter(item => item.quantity > 0) - - if (validPrices.length === 0) return null - - const prices = validPrices.map(item => item.unitPrice) - return Math.min(...prices) - } - - // 최고가 계산 - const getHighestPrice = (itemPrices: VendorPrice['itemPrices']) => { - const validPrices = itemPrices.filter(item => item.quantity > 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> - - - {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">수량</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 => item.quantity > 0) - .map((item, index) => { - const lowestPrice = getLowestPrice(vendor.itemPrices) - const highestPrice = getHighestPrice(vendor.itemPrices) - 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"> - {formatQuantity(item.quantity, item.quantityUnit)} - </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> - ) -} diff --git a/lib/bidding/detail/table/quotation-history-dialog.tsx b/lib/bidding/detail/table/quotation-history-dialog.tsx new file mode 100644 index 00000000..b816368a --- /dev/null +++ b/lib/bidding/detail/table/quotation-history-dialog.tsx @@ -0,0 +1,254 @@ +'use client' + +import * as React from 'react' +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, +} from '@/components/ui/dialog' +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from '@/components/ui/table' +import { Badge } from '@/components/ui/badge' +import { Button } from '@/components/ui/button' +import { formatDate } from '@/lib/utils' +import { History, Eye } from 'lucide-react' + +interface QuotationHistoryItem { + id: string + round: number + submittedAt: Date + totalAmount: number + currency: string + vsTargetPrice: number // 퍼센트 + items: Array<{ + itemCode: string + itemName: string + specification: string + quantity: number + unit: string + unitPrice: number + totalPrice: number + deliveryDate: Date + }> +} + +interface QuotationHistoryDialogProps { + open: boolean + onOpenChange: (open: boolean) => void + vendorName: string + history: QuotationHistoryItem[] + biddingCurrency: string + targetPrice?: number +} + +export function QuotationHistoryDialog({ + open, + onOpenChange, + vendorName, + history, + biddingCurrency, + targetPrice +}: QuotationHistoryDialogProps) { + const [selectedHistory, setSelectedHistory] = React.useState<QuotationHistoryItem | null>(null) + const [detailDialogOpen, setDetailDialogOpen] = React.useState(false) + + const handleViewDetail = (historyItem: QuotationHistoryItem) => { + setSelectedHistory(historyItem) + setDetailDialogOpen(true) + } + + const handleDetailDialogClose = () => { + setDetailDialogOpen(false) + setSelectedHistory(null) + } + + return ( + <> + <Dialog open={open} onOpenChange={onOpenChange}> + <DialogContent className="max-w-4xl max-h-[80vh] overflow-y-auto"> + <DialogHeader> + <DialogTitle className="flex items-center gap-2"> + <History className="h-5 w-5" /> + 견적 히스토리 - {vendorName} + </DialogTitle> + <DialogDescription> + {vendorName} 업체의 제출한 견적 내역을 확인할 수 있습니다. + </DialogDescription> + </DialogHeader> + + <div className="space-y-4"> + {history.length > 0 ? ( + <Table> + <TableHeader> + <TableRow> + <TableHead>차수</TableHead> + <TableHead>제출일시</TableHead> + <TableHead className="text-right">견적금액</TableHead> + <TableHead className="text-right">내정가대비 (%)</TableHead> + <TableHead className="text-center">액션</TableHead> + </TableRow> + </TableHeader> + <TableBody> + {history.map((item) => ( + <TableRow key={item.id}> + <TableCell className="font-medium"> + {item.round}차 + </TableCell> + <TableCell> + {formatDate(item.submittedAt, 'KR')} + </TableCell> + <TableCell className="text-right font-mono"> + {item.totalAmount.toLocaleString()} {item.currency} + </TableCell> + <TableCell className="text-right"> + {targetPrice && targetPrice > 0 ? ( + <Badge + variant={item.vsTargetPrice <= 0 ? 'default' : 'destructive'} + > + {item.vsTargetPrice > 0 ? '+' : ''}{item.vsTargetPrice.toFixed(1)}% + </Badge> + ) : ( + '-' + )} + </TableCell> + <TableCell className="text-center"> + <Button + variant="outline" + size="sm" + onClick={() => handleViewDetail(item)} + > + <Eye className="h-4 w-4 mr-1" /> + 상세 + </Button> + </TableCell> + </TableRow> + ))} + </TableBody> + </Table> + ) : ( + <div className="text-center py-8 text-muted-foreground"> + 제출된 견적 내역이 없습니다. + </div> + )} + </div> + </DialogContent> + </Dialog> + + {/* 상세 다이얼로그 */} + {selectedHistory && ( + <QuotationHistoryDetailDialog + open={detailDialogOpen} + onOpenChange={handleDetailDialogClose} + vendorName={vendorName} + historyItem={selectedHistory} + /> + )} + </> + ) +} + +// 견적 히스토리 상세 다이얼로그 +interface QuotationHistoryDetailDialogProps { + open: boolean + onOpenChange: (open: boolean) => void + vendorName: string + historyItem: QuotationHistoryItem +} + +function QuotationHistoryDetailDialog({ + open, + onOpenChange, + vendorName, + historyItem +}: QuotationHistoryDetailDialogProps) { + return ( + <Dialog open={open} onOpenChange={onOpenChange}> + <DialogContent className="max-w-6xl max-h-[90vh] overflow-y-auto"> + <DialogHeader> + <DialogTitle> + 견적 상세 - {vendorName} ({historyItem.round}차) + </DialogTitle> + <DialogDescription> + 제출일시: {formatDate(historyItem.submittedAt, 'KR')} + </DialogDescription> + </DialogHeader> + + <div className="space-y-4"> + {/* 요약 정보 */} + <div className="grid grid-cols-3 gap-4 p-4 bg-muted/50 rounded-lg"> + <div> + <label className="text-sm font-medium text-muted-foreground">총 견적금액</label> + <div className="text-lg font-bold"> + {historyItem.totalAmount.toLocaleString()} {historyItem.currency} + </div> + </div> + <div> + <label className="text-sm font-medium text-muted-foreground">품목 수</label> + <div className="text-lg font-bold"> + {historyItem.items.length}개 + </div> + </div> + <div> + <label className="text-sm font-medium text-muted-foreground">제출일시</label> + <div className="text-sm"> + {formatDate(historyItem.submittedAt, 'KR')} + </div> + </div> + </div> + + {/* 품목 상세 테이블 */} + <Table> + <TableHeader> + <TableRow> + <TableHead>품목코드</TableHead> + <TableHead>품목명</TableHead> + <TableHead>규격</TableHead> + <TableHead className="text-right">수량</TableHead> + <TableHead>단위</TableHead> + <TableHead className="text-right">단가</TableHead> + <TableHead className="text-right">금액</TableHead> + <TableHead>납기요청일</TableHead> + </TableRow> + </TableHeader> + <TableBody> + {historyItem.items.map((item, index) => ( + <TableRow key={index}> + <TableCell className="font-mono text-sm"> + {item.itemCode} + </TableCell> + <TableCell className="font-medium"> + {item.itemName} + </TableCell> + <TableCell className="text-sm"> + {item.specification || '-'} + </TableCell> + <TableCell className="text-right"> + {item.quantity.toLocaleString()} + </TableCell> + <TableCell>{item.unit}</TableCell> + <TableCell className="text-right font-mono"> + {item.unitPrice.toLocaleString()} {historyItem.currency} + </TableCell> + <TableCell className="text-right font-mono"> + {item.totalPrice.toLocaleString()} {historyItem.currency} + </TableCell> + <TableCell className="text-sm"> + {formatDate(item.deliveryDate, 'KR')} + </TableCell> + </TableRow> + ))} + </TableBody> + </Table> + </div> + </DialogContent> + </Dialog> + ) +} |
