summaryrefslogtreecommitdiff
path: root/lib/bidding/detail
diff options
context:
space:
mode:
Diffstat (limited to 'lib/bidding/detail')
-rw-r--r--lib/bidding/detail/service.ts120
-rw-r--r--lib/bidding/detail/table/bidding-detail-content.tsx314
-rw-r--r--lib/bidding/detail/table/bidding-detail-items-dialog.tsx12
-rw-r--r--lib/bidding/detail/table/bidding-detail-target-price-dialog.tsx356
-rw-r--r--lib/bidding/detail/table/bidding-detail-vendor-columns.tsx18
-rw-r--r--lib/bidding/detail/table/bidding-detail-vendor-table.tsx53
-rw-r--r--lib/bidding/detail/table/bidding-detail-vendor-toolbar-actions.tsx222
-rw-r--r--lib/bidding/detail/table/bidding-vendor-prices-dialog.tsx297
-rw-r--r--lib/bidding/detail/table/quotation-history-dialog.tsx254
9 files changed, 488 insertions, 1158 deletions
diff --git a/lib/bidding/detail/service.ts b/lib/bidding/detail/service.ts
index d58ded8e..b5a3cce8 100644
--- a/lib/bidding/detail/service.ts
+++ b/lib/bidding/detail/service.ts
@@ -1,13 +1,14 @@
'use server'
import db from '@/db/db'
-import { biddings, prItemsForBidding, biddingDocuments, biddingCompanies, vendors, companyPrItemBids, companyConditionResponses, vendorSelectionResults, priceAdjustmentForms, users } from '@/db/schema'
-import { specificationMeetings } from '@/db/schema/bidding'
+import { biddings, prItemsForBidding, biddingDocuments, biddingCompanies, vendors, companyPrItemBids, companyConditionResponses, vendorSelectionResults, priceAdjustmentForms, users, vendorContacts } from '@/db/schema'
+import { specificationMeetings, biddingCompaniesContacts } from '@/db/schema/bidding'
import { eq, and, sql, desc, ne } from 'drizzle-orm'
import { revalidatePath, revalidateTag } from 'next/cache'
import { unstable_cache } from "@/lib/unstable-cache";
import { sendEmail } from '@/lib/mail/sendEmail'
import { saveFile } from '@/lib/file-stroage'
+import { sendBiddingNoticeSms } from '@/lib/users/auth/passwordUtil'
// userId를 user.name으로 변환하는 유틸리티 함수
async function getUserNameById(userId: string): Promise<string> {
@@ -205,28 +206,20 @@ export async function getBiddingCompaniesData(biddingId: number) {
}
}
-// prItemsForBidding 테이블에서 품목 정보 조회 (캐시 적용)
+// prItemsForBidding 테이블에서 품목 정보 조회 (캐시 미적용, always fresh)
export async function getPRItemsForBidding(biddingId: number) {
- return unstable_cache(
- async () => {
- try {
- const items = await db
- .select()
- .from(prItemsForBidding)
- .where(eq(prItemsForBidding.biddingId, biddingId))
- .orderBy(prItemsForBidding.id)
+ try {
+ const items = await db
+ .select()
+ .from(prItemsForBidding)
+ .where(eq(prItemsForBidding.biddingId, biddingId))
+ .orderBy(prItemsForBidding.id)
- return items
- } catch (error) {
- console.error('Failed to get PR items for bidding:', error)
- return []
- }
- },
- [`pr-items-for-bidding-${biddingId}`],
- {
- tags: [`bidding-${biddingId}`, 'pr-items']
- }
- )()
+ return items
+ } catch (error) {
+ console.error('Failed to get PR items for bidding:', error)
+ return []
+ }
}
// 견적 시스템에서 협력업체 정보를 가져오는 함수 (캐시 적용)
@@ -757,10 +750,10 @@ export async function markAsDisposal(biddingId: number, userId: string) {
}
}
-// 입찰 등록 (사전견적에서 선정된 업체들에게 본입찰 초대 발송)
+// 입찰 등록 ( 본입찰 초대 발송)
export async function registerBidding(biddingId: number, userId: string) {
try {
- // 사전견적에서 선정된 업체들 + 본입찰에서 개별적으로 추가한 업체들 조회
+ // 본입찰에서 개별적으로 추가한 업체들 조회
const selectedCompanies = await db
.select({
companyId: biddingCompanies.companyId,
@@ -769,10 +762,9 @@ export async function registerBidding(biddingId: number, userId: string) {
})
.from(biddingCompanies)
.leftJoin(vendors, eq(biddingCompanies.companyId, vendors.id))
- .where(and(
- eq(biddingCompanies.biddingId, biddingId),
- eq(biddingCompanies.isPreQuoteSelected, true)
- ))
+ .where(
+ eq(biddingCompanies.biddingId, biddingId)
+ )
// 입찰 정보 조회
const biddingInfo = await db
@@ -843,7 +835,37 @@ export async function registerBidding(biddingId: number, userId: string) {
}
}
}
+ // 4. 입찰 공고 SMS 알림 전송
+ for (const company of selectedCompanies) {
+ // biddingCompaniesContacts에서 모든 연락처 전화번호 조회
+ const contactInfos = await db
+ .select({
+ contactNumber: biddingCompaniesContacts.contactNumber
+ })
+ .from(biddingCompaniesContacts)
+ .where(and(
+ eq(biddingCompaniesContacts.biddingId, biddingId),
+ eq(biddingCompaniesContacts.vendorId, company.companyId)
+ ));
+
+ // 각 연락처에 SMS 전송
+ for (const contactInfo of contactInfos) {
+ const contactPhone = contactInfo.contactNumber;
+ if (contactPhone) {
+ try {
+ const smsResult = await sendBiddingNoticeSms(contactPhone, bidding.title);
+ if (smsResult.success) {
+ console.log(`입찰 공고 SMS 전송 성공: ${contactPhone} - ${bidding.title}`);
+ } else {
+ console.error(`입찰 공고 SMS 전송 실패: ${contactPhone} - ${smsResult.error}`);
+ }
+ } catch (smsError) {
+ console.error(`Failed to send bidding notice SMS to ${contactPhone}:`, smsError)
+ }
+ }
+ }
+ }
// 캐시 무효화
revalidateTag(`bidding-${biddingId}`)
revalidateTag('bidding-detail')
@@ -1352,7 +1374,7 @@ export async function updateBiddingParticipation(
return {
success: true,
- message: `입찰 참여상태가 ${participated ? '응찰' : '미응찰'}로 업데이트되었습니다.`,
+ message: `입찰 참여상태가 ${participated ? '응찰' :'응찰포기'}로 업데이트되었습니다.`,
}
} catch (error) {
console.error('Failed to update bidding participation:', error)
@@ -1483,7 +1505,7 @@ export async function updatePartnerBiddingParticipation(
return {
success: true,
- message: `입찰 참여상태가 ${participated ? '응찰' : '미응찰'}로 업데이트되었습니다.`,
+ message: `입찰 참여상태가 ${participated ? '응찰' : '응찰포기'}로 업데이트되었습니다.`,
}
} catch (error) {
console.error('Failed to update partner bidding participation:', error)
@@ -1802,8 +1824,6 @@ export async function submitPartnerResponse(
}
}
-
-
// 3. 연동제 정보 저장 (연동제 적용이 true이고 연동제 정보가 있는 경우)
// if (response.priceAdjustmentResponse && response.priceAdjustmentForm) {
// const priceAdjustmentData = {
@@ -1854,8 +1874,8 @@ export async function submitPartnerResponse(
if (response.finalQuoteAmount !== undefined) {
companyUpdateData.finalQuoteAmount = response.finalQuoteAmount
companyUpdateData.finalQuoteSubmittedAt = new Date()
-
- // 최종제출 여부에 따라 상태 및 플래그 설정
+
+ // isFinalSubmission에 따라 상태 및 플래그 설정
if (response.isFinalSubmission) {
companyUpdateData.isFinalSubmission = true
companyUpdateData.invitationStatus = 'bidding_submitted' // 응찰 완료
@@ -1863,6 +1883,38 @@ export async function submitPartnerResponse(
companyUpdateData.isFinalSubmission = false
// 임시저장: invitationStatus는 변경하지 않음 (bidding_accepted 유지)
}
+
+ // 스냅샷은 임시저장/최종제출 관계없이 항상 생성
+ if (response.prItemQuotations && response.prItemQuotations.length > 0) {
+ // 기존 스냅샷 조회
+ const existingCompany = await tx
+ .select({ quotationSnapshots: biddingCompanies.quotationSnapshots })
+ .from(biddingCompanies)
+ .where(eq(biddingCompanies.id, biddingCompanyId))
+ .limit(1)
+
+ const existingSnapshots = existingCompany[0]?.quotationSnapshots as any[] || []
+
+ // 새로운 스냅샷 생성
+ const newSnapshot = {
+ id: Date.now().toString(), // 고유 ID
+ round: existingSnapshots.length + 1, // 차수
+ submittedAt: new Date().toISOString(),
+ totalAmount: response.finalQuoteAmount,
+ currency: 'KRW',
+ isFinalSubmission: !!response.isFinalSubmission,
+ items: response.prItemQuotations.map(item => ({
+ prItemId: item.prItemId,
+ bidUnitPrice: item.bidUnitPrice,
+ bidAmount: item.bidAmount,
+ proposedDeliveryDate: item.proposedDeliveryDate,
+ technicalSpecification: item.technicalSpecification
+ }))
+ }
+
+ // 스냅샷 배열에 추가
+ companyUpdateData.quotationSnapshots = [...existingSnapshots, newSnapshot]
+ }
}
await tx
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>
+ )
+}