From 8642ee064ddf96f1db2b948b4cc8bbbd6cfee820 Mon Sep 17 00:00:00 2001 From: dujinkim Date: Wed, 12 Nov 2025 10:42:36 +0000 Subject: (최겸) 구매 일반계약, 입찰 수정 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../evcp/(evcp)/(procurement)/bid-failure/page.tsx | 4 +- .../evcp/(evcp)/(procurement)/bid-receive/page.tsx | 4 +- .../bid-selection/[id]/detail/page.tsx | 69 ++++ components/bidding/bidding-round-actions.tsx | 32 +- .../bidding/manage/bidding-basic-info-editor.tsx | 39 ++- components/bidding/manage/bidding-items-editor.tsx | 204 +++++++----- .../bidding/manage/create-pre-quote-rfq-dialog.tsx | 2 + .../procurement-item-selector-dialog-single.tsx | 168 ++++++++++ .../procurement-item/procurement-item-selector.tsx | 176 ++++++++++ .../procurement-item/procurement-item-service.ts | 55 ++++ components/ui/file-upload.tsx | 169 ++++++++++ config/menuConfig.ts | 24 +- db/schema/bidding.ts | 18 +- i18n/locales/ko/menu.json | 2 +- lib/bidding/actions.ts | 230 ++++++++++++- lib/bidding/detail/service.ts | 120 +++++-- .../detail/table/bidding-detail-content.tsx | 314 ------------------ .../detail/table/bidding-detail-items-dialog.tsx | 12 +- .../table/bidding-detail-target-price-dialog.tsx | 356 --------------------- .../detail/table/bidding-detail-vendor-columns.tsx | 18 +- .../detail/table/bidding-detail-vendor-table.tsx | 53 ++- .../bidding-detail-vendor-toolbar-actions.tsx | 222 +++++-------- .../detail/table/bidding-vendor-prices-dialog.tsx | 297 ----------------- .../detail/table/quotation-history-dialog.tsx | 254 +++++++++++++++ lib/bidding/failure/biddings-closure-dialog.tsx | 142 ++++++++ lib/bidding/failure/biddings-failure-columns.tsx | 130 +++++--- lib/bidding/failure/biddings-failure-table.tsx | 266 +++++++++++++-- lib/bidding/list/bidding-detail-dialogs.tsx | 122 ------- lib/bidding/list/biddings-table-columns.tsx | 13 +- lib/bidding/list/biddings-table.tsx | 21 +- lib/bidding/list/create-bidding-dialog.tsx | 2 - lib/bidding/receive/biddings-receive-columns.tsx | 86 ++--- lib/bidding/receive/biddings-receive-table.tsx | 149 +++++++-- lib/bidding/selection/actions.ts | 219 +++++++++++++ lib/bidding/selection/bidding-info-card.tsx | 96 ++++++ .../selection/bidding-selection-detail-content.tsx | 41 +++ .../selection/biddings-selection-columns.tsx | 4 - lib/bidding/selection/biddings-selection-table.tsx | 4 - lib/bidding/selection/selection-result-form.tsx | 143 +++++++++ lib/bidding/selection/vendor-selection-table.tsx | 66 ++++ lib/bidding/service.ts | 96 +++++- lib/bidding/vendor/partners-bidding-detail.tsx | 6 +- .../detail/general-contract-info-header.tsx | 3 +- .../main/create-general-contract-dialog.tsx | 3 +- .../main/general-contract-update-sheet.tsx | 3 +- .../main/general-contracts-table-columns.tsx | 2 + .../main/general-contracts-table.tsx | 3 +- lib/general-contracts/service.ts | 3 +- lib/general-contracts/types.ts | 3 +- lib/users/auth/passwordUtil.ts | 83 ++++- 50 files changed, 2963 insertions(+), 1588 deletions(-) create mode 100644 app/[lng]/evcp/(evcp)/(procurement)/bid-selection/[id]/detail/page.tsx create mode 100644 components/common/selectors/procurement-item/procurement-item-selector-dialog-single.tsx create mode 100644 components/common/selectors/procurement-item/procurement-item-selector.tsx create mode 100644 components/common/selectors/procurement-item/procurement-item-service.ts create mode 100644 components/ui/file-upload.tsx delete mode 100644 lib/bidding/detail/table/bidding-detail-content.tsx delete mode 100644 lib/bidding/detail/table/bidding-detail-target-price-dialog.tsx delete mode 100644 lib/bidding/detail/table/bidding-vendor-prices-dialog.tsx create mode 100644 lib/bidding/detail/table/quotation-history-dialog.tsx create mode 100644 lib/bidding/failure/biddings-closure-dialog.tsx create mode 100644 lib/bidding/selection/actions.ts create mode 100644 lib/bidding/selection/bidding-info-card.tsx create mode 100644 lib/bidding/selection/bidding-selection-detail-content.tsx create mode 100644 lib/bidding/selection/selection-result-form.tsx create mode 100644 lib/bidding/selection/vendor-selection-table.tsx diff --git a/app/[lng]/evcp/(evcp)/(procurement)/bid-failure/page.tsx b/app/[lng]/evcp/(evcp)/(procurement)/bid-failure/page.tsx index f460f570..b6c181dc 100644 --- a/app/[lng]/evcp/(evcp)/(procurement)/bid-failure/page.tsx +++ b/app/[lng]/evcp/(evcp)/(procurement)/bid-failure/page.tsx @@ -4,7 +4,7 @@ import { GetBiddingsSchema, searchParamsCache } from '@/lib/bidding/validation' import { BiddingsFailureTable } from '@/lib/bidding/failure/biddings-failure-table' export const metadata: Metadata = { - title: '유찰입찰', + title: '폐찰 및 재입찰', description: '유찰된 입찰 내역을 확인하고 재입찰을 진행할 수 있습니다.', } @@ -26,7 +26,7 @@ export default async function BiddingFailurePage({
-

유찰입찰

+

폐찰 및 재입찰

유찰된 입찰 내역을 확인하고 재입찰을 진행할 수 있습니다.

diff --git a/app/[lng]/evcp/(evcp)/(procurement)/bid-receive/page.tsx b/app/[lng]/evcp/(evcp)/(procurement)/bid-receive/page.tsx index 0d725bbf..4f6e9715 100644 --- a/app/[lng]/evcp/(evcp)/(procurement)/bid-receive/page.tsx +++ b/app/[lng]/evcp/(evcp)/(procurement)/bid-receive/page.tsx @@ -4,7 +4,7 @@ import { GetBiddingsSchema, searchParamsCache } from '@/lib/bidding/validation' import { BiddingsReceiveTable } from '@/lib/bidding/receive/biddings-receive-table' export const metadata: Metadata = { - title: '입찰서접수및마감', + title: '입찰서 접수 및 마감', description: '입찰서 접수 및 마감 현황을 확인하고 개찰을 진행할 수 있습니다.', } @@ -26,7 +26,7 @@ export default async function BiddingReceivePage({
-

입찰서접수및마감

+

입찰서 접수 및 마감

입찰서 접수 현황을 확인하고 개찰을 진행할 수 있습니다.

diff --git a/app/[lng]/evcp/(evcp)/(procurement)/bid-selection/[id]/detail/page.tsx b/app/[lng]/evcp/(evcp)/(procurement)/bid-selection/[id]/detail/page.tsx new file mode 100644 index 00000000..1456564f --- /dev/null +++ b/app/[lng]/evcp/(evcp)/(procurement)/bid-selection/[id]/detail/page.tsx @@ -0,0 +1,69 @@ +import { notFound } from 'next/navigation' +import { getBiddingById } from "@/lib/bidding/service" +import { Bidding } from "@/db/schema/bidding" +import { Button } from "@/components/ui/button" +import { ArrowLeft } from "lucide-react" +import Link from "next/link" +import { BiddingSelectionDetailContent } from "@/lib/bidding/selection/bidding-selection-detail-content" + +// 메타데이터 생성 +export async function generateMetadata({ params }: { params: Promise<{ lng: string; id: string }> }) { + const { lng, id } = await params + const parsedId = parseInt(id) + if (isNaN(parsedId)) return { title: '입찰선정 상세보기' } + + try { + const bidding = await getBiddingById(parsedId) + return { + title: bidding ? `${bidding.title} - 입찰선정 상세보기` : '입찰선정 상세보기', + } + } catch { + return { title: '입찰선정 상세보기' } + } +} + +interface PageProps { + params: Promise<{ lng: string; id: string }> +} + +export default async function BiddingSelectionDetailPage({ params }: PageProps) { + const { lng, id } = await params + const parsedId = parseInt(id) + + if (isNaN(parsedId)) { + notFound() + } + + const bidding: Bidding | null = await getBiddingById(parsedId) + + if (!bidding) { + notFound() + } + + return ( +
+ {/* 헤더 */} +
+
+
+

+ 입찰선정 상세보기 +

+

+ 입찰 No. {bidding.biddingNumber ?? ""} - {bidding.title} +

+
+
+ + + +
+ + {/* 입찰선정 상세 콘텐츠 */} + +
+ ) +} diff --git a/components/bidding/bidding-round-actions.tsx b/components/bidding/bidding-round-actions.tsx index b2db0dfb..86fea72a 100644 --- a/components/bidding/bidding-round-actions.tsx +++ b/components/bidding/bidding-round-actions.tsx @@ -35,16 +35,27 @@ export function BiddingRoundActions({ biddingId, biddingStatus }: BiddingRoundAc const { data: session } = useSession() const userId = session?.user?.id - // 차수증가는 유찰 상태에서만 가능 - const canIncreaseRound = biddingStatus === 'bidding_disposal' - - // 재입찰도 유찰 상태에서만 가능 + // 차수증가는 입찰공고 또는 입찰 진행중 상태에서 가능 + const canIncreaseRound = biddingStatus === 'bidding_generated' || biddingStatus === 'bidding_opened' + + // 유찰 및 낙찰은 입찰 진행중 상태에서 가능 (이 컴포넌트에서는 사용하지 않음) + + // 재입찰은 유찰 상태에서만 가능 const canRebid = biddingStatus === 'bidding_disposal' const handleRoundIncrease = () => { + if (!userId) { + toast({ + title: "오류", + description: "사용자 정보를 찾을 수 없습니다.", + variant: "destructive", + }) + return + } + const userIdStr = userId as string startTransition(async () => { try { - const result = await increaseRoundOrRebid(biddingId, userId, 'round_increase') + const result = await increaseRoundOrRebid(biddingId, userIdStr, 'round_increase') if (result.success) { toast({ @@ -77,9 +88,18 @@ export function BiddingRoundActions({ biddingId, biddingStatus }: BiddingRoundAc } const handleRebid = () => { + if (!userId) { + toast({ + title: "오류", + description: "사용자 정보를 찾을 수 없습니다.", + variant: "destructive", + }) + return + } + const userIdStr = userId as string startTransition(async () => { try { - const result = await increaseRoundOrRebid(biddingId, userId, 'rebidding') + const result = await increaseRoundOrRebid(biddingId, userIdStr, 'rebidding') if (result.success) { toast({ diff --git a/components/bidding/manage/bidding-basic-info-editor.tsx b/components/bidding/manage/bidding-basic-info-editor.tsx index d60c5d88..a956d73c 100644 --- a/components/bidding/manage/bidding-basic-info-editor.tsx +++ b/components/bidding/manage/bidding-basic-info-editor.tsx @@ -26,8 +26,9 @@ import { Textarea } from '@/components/ui/textarea' import { Switch } from '@/components/ui/switch' import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card' // CreateBiddingInput 타입 정의가 없으므로 CreateBiddingSchema를 확장하여 사용합니다. -import { getBiddingById, updateBiddingBasicInfo, getBiddingConditions, getBiddingNotice, updateBiddingConditions } from '@/lib/bidding/service' -import { getBiddingNoticeTemplate } from '@/lib/bidding/service' +import { getBiddingById, updateBiddingBasicInfo, getBiddingConditions, getBiddingNotice, updateBiddingConditions, getBiddingNoticeTemplate } from '@/lib/bidding/service' +import { getPurchaseGroupCodes } from '@/components/common/selectors/purchase-group-code' +import { getProcurementManagers } from '@/components/common/selectors/procurement-manager' import { getIncotermsForSelection, getPaymentTermsForSelection, @@ -39,8 +40,8 @@ import { contractTypeLabels, biddingTypeLabels, awardCountLabels, biddingNoticeT import TiptapEditor from '@/components/qna/tiptap-editor' import { PurchaseGroupCodeSelector } from '@/components/common/selectors/purchase-group-code' import { ProcurementManagerSelector } from '@/components/common/selectors/procurement-manager' -import type { PurchaseGroupCodeWithUser } from '@/components/common/selectors/purchase-group-code/purchase-group-code-service' -import type { ProcurementManagerWithUser } from '@/components/common/selectors/procurement-manager/procurement-manager-service' +import type { PurchaseGroupCodeWithUser } from '@/components/common/selectors/purchase-group-code' +import type { ProcurementManagerWithUser } from '@/components/common/selectors/procurement-manager' import { getBiddingDocuments, uploadBiddingDocument, deleteBiddingDocument } from '@/lib/bidding/detail/service' import { downloadFile } from '@/lib/file-download' @@ -259,7 +260,7 @@ export function BiddingBasicInfoEditor({ biddingId }: BiddingBasicInfoEditorProp } // Procurement 데이터 로드 - const [paymentTermsData, incotermsData, shippingData, destinationData] = await Promise.all([ + const [paymentTermsData, incotermsData, shippingData, destinationData, purchaseGroupCodes, procurementManagers] = await Promise.all([ getPaymentTermsForSelection().catch(() => []), getIncotermsForSelection().catch(() => []), getPlaceOfShippingForSelection().catch(() => []), @@ -269,6 +270,34 @@ export function BiddingBasicInfoEditor({ biddingId }: BiddingBasicInfoEditorProp setIncotermsOptions(incotermsData) setShippingPlaces(shippingData) setDestinationPlaces(destinationData) + setSelectedBidPic({ + DISPLAY_NAME: bidding.bidPicName || '', + PURCHASE_GROUP_CODE: bidding.bidPicCode || '', + user: { + id: bidding.bidPicUserId || undefined, + } + }) + setSelectedSupplyPic({ + DISPLAY_NAME: bidding.supplyPicName || '', + PROCUREMENT_MANAGER_CODE: bidding.supplyPicCode || '', + user: { + id: bidding.supplyPicUserId || undefined, + } + }) + // // 입찰담당자 및 조달담당자 초기 선택값 설정 + // if (bidding.bidPicCode && purchaseGroupCodes.length > 0) { + // const selectedBidPicData = purchaseGroupCodes.find(code => code.PURCHASE_GROUP_CODE === bidding.bidPicCode) + // if (selectedBidPicData) { + + // } + // } + + // if (bidding.supplyPicCode && procurementManagers.length > 0) { + // const selectedSupplyPicData = procurementManagers.find(manager => manager.PROCUREMENT_MANAGER_CODE === bidding.supplyPicCode) + // if (selectedSupplyPicData) { + + // } + // } // 공고 템플릿 로드 await loadNoticeTemplate(biddingExtended.noticeType || undefined) diff --git a/components/bidding/manage/bidding-items-editor.tsx b/components/bidding/manage/bidding-items-editor.tsx index 96a8d2ae..dc0aaeec 100644 --- a/components/bidding/manage/bidding-items-editor.tsx +++ b/components/bidding/manage/bidding-items-editor.tsx @@ -80,6 +80,7 @@ interface BiddingItemsEditorProps { import { removeBiddingItem, addPRItemForBidding, getBiddingById, getBiddingConditions } from '@/lib/bidding/service' import { CreatePreQuoteRfqDialog } from './create-pre-quote-rfq-dialog' +import { ProcurementItemSelectorDialogSingle } from '@/components/common/selectors/procurement-item/procurement-item-selector-dialog-single' import { Textarea } from '@/components/ui/textarea' import { Label } from '@/components/ui/label' @@ -100,6 +101,7 @@ export function BiddingItemsEditor({ biddingId }: BiddingItemsEditorProps) { const [preQuoteDialogOpen, setPreQuoteDialogOpen] = React.useState(false) const [targetPriceCalculationCriteria, setTargetPriceCalculationCriteria] = React.useState('') const [biddingPicUserId, setBiddingPicUserId] = React.useState(null) + const [biddingType, setBiddingType] = React.useState(null) const [biddingConditions, setBiddingConditions] = React.useState<{ paymentTerms?: string | null taxConditions?: string | null @@ -159,13 +161,15 @@ export function BiddingItemsEditor({ biddingId }: BiddingItemsEditorProps) { actualAmount: item.actualAmount ? item.actualAmount.toString() : null, actualCurrency: item.actualCurrency || 'KRW', })) - + // 첫 번째 아이템을 대표로 설정 - formattedItems[0].isRepresentative = true - + if (formattedItems.length > 0) { + formattedItems[0].isRepresentative = true + } + setItems(formattedItems) setDeletedItemIds(new Set()) // 삭제 목록 초기화 - + // 기존 품목 로드 성공 알림 (조용히 표시, 선택적) console.log(`기존 품목 ${formattedItems.length}개를 불러왔습니다.`) } else { @@ -199,7 +203,9 @@ export function BiddingItemsEditor({ biddingId }: BiddingItemsEditorProps) { ]) if (bidding) { + console.log('📋 bidding:', bidding.biddingType) setBiddingPicUserId(bidding.bidPicId || null) + setBiddingType(bidding.biddingType || null) setTargetPriceCalculationCriteria(bidding.targetPriceCalculationCriteria || '') } @@ -332,52 +338,73 @@ export function BiddingItemsEditor({ biddingId }: BiddingItemsEditorProps) { toast.success('품목 정보가 성공적으로 저장되었습니다.') // 삭제 목록 초기화 setDeletedItemIds(new Set()) + // 데이터 다시 로딩하여 최신 상태 반영 + console.log('🔄 저장 후 데이터 재로드 시작 - biddingId:', biddingId) const prItems = await getPRItemsForBidding(biddingId) - const formattedItems: PRItemInfo[] = prItems.map((item) => ({ - id: item.id, - prNumber: item.prNumber || null, - projectId: item.projectId || null, - projectInfo: item.projectInfo || null, - shi: item.shi || null, - quantity: item.quantity ? item.quantity.toString() : null, - quantityUnit: item.quantityUnit || null, - totalWeight: item.totalWeight ? item.totalWeight.toString() : null, - weightUnit: item.weightUnit || null, - materialDescription: item.itemInfo || null, - hasSpecDocument: item.hasSpecDocument || false, - requestedDeliveryDate: item.requestedDeliveryDate ? new Date(item.requestedDeliveryDate).toISOString().split('T')[0] : null, - isRepresentative: false, - annualUnitPrice: item.annualUnitPrice ? item.annualUnitPrice.toString() : null, - currency: item.currency || 'KRW', - materialGroupNumber: item.materialGroupNumber || null, - materialGroupInfo: item.materialGroupInfo || null, - materialNumber: item.materialNumber || null, - materialInfo: item.materialInfo || null, - priceUnit: item.priceUnit || null, - purchaseUnit: item.purchaseUnit || null, - materialWeight: item.materialWeight ? item.materialWeight.toString() : null, - wbsCode: item.wbsCode || null, - wbsName: item.wbsName || null, - costCenterCode: item.costCenterCode || null, - costCenterName: item.costCenterName || null, - glAccountCode: item.glAccountCode || null, - glAccountName: item.glAccountName || null, - targetUnitPrice: item.targetUnitPrice ? item.targetUnitPrice.toString() : null, - targetAmount: item.targetAmount ? item.targetAmount.toString() : null, - targetCurrency: item.targetCurrency || 'KRW', - budgetAmount: item.budgetAmount ? item.budgetAmount.toString() : null, - budgetCurrency: item.budgetCurrency || 'KRW', - actualAmount: item.actualAmount ? item.actualAmount.toString() : null, - actualCurrency: item.actualCurrency || 'KRW', - })) - - // 첫 번째 아이템을 대표로 설정 - if (formattedItems.length > 0) { - formattedItems[0].isRepresentative = true + console.log('📦 getPRItemsForBidding 결과:', prItems) + + if (prItems && prItems.length > 0) { + console.log('✅ 저장된 아이템 수:', prItems.length) + const formattedItems: PRItemInfo[] = prItems.map((item, index) => { + console.log(`🔍 아이템 ${index + 1}:`, { + id: item.id, + materialGroupNumber: item.materialGroupNumber, + materialNumber: item.materialNumber, + quantity: item.quantity + }) + return { + id: item.id, + prNumber: item.prNumber || null, + projectId: item.projectId || null, + projectInfo: item.projectInfo || null, + shi: item.shi || null, + quantity: item.quantity ? item.quantity.toString() : null, + quantityUnit: item.quantityUnit || null, + totalWeight: item.totalWeight ? item.totalWeight.toString() : null, + weightUnit: item.weightUnit || null, + materialDescription: item.itemInfo || null, + hasSpecDocument: item.hasSpecDocument || false, + requestedDeliveryDate: item.requestedDeliveryDate ? new Date(item.requestedDeliveryDate).toISOString().split('T')[0] : null, + isRepresentative: false, + annualUnitPrice: item.annualUnitPrice ? item.annualUnitPrice.toString() : null, + currency: item.currency || 'KRW', + materialGroupNumber: item.materialGroupNumber || null, + materialGroupInfo: item.materialGroupInfo || null, + materialNumber: item.materialNumber || null, + materialInfo: item.materialInfo || null, + priceUnit: item.priceUnit || null, + purchaseUnit: item.purchaseUnit || null, + materialWeight: item.materialWeight ? item.materialWeight.toString() : null, + wbsCode: item.wbsCode || null, + wbsName: item.wbsName || null, + costCenterCode: item.costCenterCode || null, + costCenterName: item.costCenterName || null, + glAccountCode: item.glAccountCode || null, + glAccountName: item.glAccountName || null, + targetUnitPrice: item.targetUnitPrice ? item.targetUnitPrice.toString() : null, + targetAmount: item.targetAmount ? item.targetAmount.toString() : null, + targetCurrency: item.targetCurrency || 'KRW', + budgetAmount: item.budgetAmount ? item.budgetAmount.toString() : null, + budgetCurrency: item.budgetCurrency || 'KRW', + actualAmount: item.actualAmount ? item.actualAmount.toString() : null, + actualCurrency: item.actualCurrency || 'KRW', + } + }) + + // 첫 번째 아이템을 대표로 설정 + if (formattedItems.length > 0) { + formattedItems[0].isRepresentative = true + } + + console.log('📋 최종 formattedItems:', formattedItems) + setItems(formattedItems) + console.log('✅ 상태 업데이트 완료') + } else { + console.log('❌ 저장 후 데이터가 없음 - 빈 배열 설정') + // 저장 후 데이터가 없으면 빈 배열로 설정 + setItems([]) } - - setItems(formattedItems) } } catch (error) { console.error('Failed to save items:', error) @@ -593,30 +620,57 @@ export function BiddingItemsEditor({ biddingId }: BiddingItemsEditorProps) { /> - { - if (material) { - updatePRItem(item.id, { - materialGroupNumber: material.materialGroupCode, - materialGroupInfo: material.materialGroupDescription - }) - } else { - updatePRItem(item.id, { - materialGroupNumber: '', - materialGroupInfo: '' - }) - } - }} - title="자재그룹 선택" - description="자재그룹을 검색하고 선택해주세요." - /> + {biddingType === 'equipment' ? ( + { + if (procurementItem) { + updatePRItem(item.id, { + materialGroupNumber: procurementItem.itemCode, + materialGroupInfo: procurementItem.itemName + }) + } else { + updatePRItem(item.id, { + materialGroupNumber: '', + materialGroupInfo: '' + }) + } + }} + title="품목 선택" + description="품목을 검색하고 선택해주세요." + /> + ) : ( + { + if (material) { + updatePRItem(item.id, { + materialGroupNumber: material.materialGroupCode, + materialGroupInfo: material.materialGroupDescription + }) + } else { + updatePRItem(item.id, { + materialGroupNumber: '', + materialGroupInfo: '' + }) + } + }} + title="자재그룹 선택" + description="자재그룹을 검색하고 선택해주세요." + /> + )} { if (material) { @@ -830,7 +884,7 @@ export function BiddingItemsEditor({ biddingId }: BiddingItemsEditorProps) { > {item.wbsCode ? ( - {`${item.wbsCode}${item.wbsName ? ` - ${item.wbsName}` : ''}`} + {`${item.wbsCode}`} ) : ( WBS 코드 선택 @@ -880,7 +934,7 @@ export function BiddingItemsEditor({ biddingId }: BiddingItemsEditorProps) { > {item.costCenterCode ? ( - {`${item.costCenterCode}${item.costCenterName ? ` - ${item.costCenterName}` : ''}`} + {`${item.costCenterCode}`} ) : ( 코스트센터 선택 @@ -931,7 +985,7 @@ export function BiddingItemsEditor({ biddingId }: BiddingItemsEditorProps) { > {item.glAccountCode ? ( - {`${item.glAccountCode}${item.glAccountName ? ` - ${item.glAccountName}` : ''}`} + {`${item.glAccountCode}`} ) : ( GL계정 선택 diff --git a/components/bidding/manage/create-pre-quote-rfq-dialog.tsx b/components/bidding/manage/create-pre-quote-rfq-dialog.tsx index 88732deb..c49f6232 100644 --- a/components/bidding/manage/create-pre-quote-rfq-dialog.tsx +++ b/components/bidding/manage/create-pre-quote-rfq-dialog.tsx @@ -95,6 +95,7 @@ interface CreatePreQuoteRfqDialogProps { totalWeight?: string | null weightUnit?: string | null }> + picUserId?: number | null biddingConditions?: { paymentTerms?: string | null taxConditions?: string | null @@ -114,6 +115,7 @@ export function CreatePreQuoteRfqDialog({ onOpenChange, biddingId, biddingItems, + picUserId, biddingConditions, onSuccess }: CreatePreQuoteRfqDialogProps) { diff --git a/components/common/selectors/procurement-item/procurement-item-selector-dialog-single.tsx b/components/common/selectors/procurement-item/procurement-item-selector-dialog-single.tsx new file mode 100644 index 00000000..dab65780 --- /dev/null +++ b/components/common/selectors/procurement-item/procurement-item-selector-dialog-single.tsx @@ -0,0 +1,168 @@ +"use client"; + +import React, { useState, useCallback } from "react"; +import { Button } from "@/components/ui/button"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, + DialogTrigger, +} from "@/components/ui/dialog"; +import { ProcurementItemSelector } from "./procurement-item-selector"; +import { ProcurementSearchItem } from "./procurement-item-service"; + +export interface ProcurementItemSelectorDialogSingleProps { + triggerLabel?: string; + triggerVariant?: "default" | "destructive" | "outline" | "secondary" | "ghost" | "link"; + triggerSize?: "default" | "sm" | "lg" | "icon"; + selectedProcurementItem?: ProcurementSearchItem | null; + onProcurementItemSelect?: (item: ProcurementSearchItem | null) => void; + title?: string; + description?: string; + showConfirmButtons?: boolean; +} + +/** + * 품목 단일 선택 Dialog 컴포넌트 + * + * @description + * - ProcurementItemSelector를 Dialog로 래핑한 단일 선택 컴포넌트 + * - 버튼 클릭 시 Dialog가 열리고, 품목을 선택하면 Dialog가 닫히며 결과를 반환 + * + * @ProcurementSearchItem_Structure + * 상태에서 관리되는 품목 객체의 형태: + * ```typescript + * interface ProcurementSearchItem { + * itemCode: string; // 품목코드 + * itemName: string; // 품목명 + * material?: string; // 재질 + * specification?: string; // 규격 + * unit?: string; // 단위 + * displayText: string; // 표시용 텍스트 (code + " - " + name) + * } + * ``` + * + * @state + * - open: Dialog 열림/닫힘 상태 + * - selectedProcurementItem: 현재 선택된 품목 (단일) + * - tempSelectedProcurementItem: Dialog 내에서 임시로 선택된 품목 (확인 버튼 클릭 전까지) + * + * @callback + * - onProcurementItemSelect: 품목 선택 완료 시 호출되는 콜백 + * - 매개변수: ProcurementSearchItem | null + * - 선택된 품목 정보 또는 null (선택 해제 시) + * + * @usage + * ```tsx + * { + * console.log('선택된 품목:', item); + * setSelectedProcurementItem(item); + * }} + * title="품목 선택" + * description="품목을 검색하고 선택해주세요." + * /> + * ``` + */ +export function ProcurementItemSelectorDialogSingle({ + triggerLabel = "품목 선택", + triggerVariant = "outline", + triggerSize = "default", + selectedProcurementItem = null, + onProcurementItemSelect, + title = "품목 선택", + description = "품목을 검색하고 선택해주세요.", + showConfirmButtons = false, +}: ProcurementItemSelectorDialogSingleProps) { + const [open, setOpen] = useState(false); + const [tempSelectedProcurementItem, setTempSelectedProcurementItem] = + useState(selectedProcurementItem); + + // Dialog가 열릴 때 임시 선택 상태 초기화 + const handleOpenChange = useCallback((newOpen: boolean) => { + setOpen(newOpen); + if (newOpen) { + // Dialog 열 때 현재 선택된 값으로 임시 상태 초기화 + setTempSelectedProcurementItem(selectedProcurementItem); + } + }, [selectedProcurementItem]); + + // 품목 선택 시 임시 상태 업데이트 + const handleProcurementItemSelect = useCallback((item: ProcurementSearchItem | null) => { + setTempSelectedProcurementItem(item); + + // 확인 버튼이 없는 경우 즉시 적용하고 Dialog 닫기 + if (!showConfirmButtons) { + onProcurementItemSelect?.(item); + setOpen(false); + } + }, [onProcurementItemSelect, showConfirmButtons]); + + // 확인 버튼 클릭 시 선택 적용 + const handleConfirm = useCallback(() => { + onProcurementItemSelect?.(tempSelectedProcurementItem); + setOpen(false); + }, [onProcurementItemSelect, tempSelectedProcurementItem]); + + // 취소 버튼 클릭 시 Dialog 닫기 (변경사항 적용 안 함) + const handleCancel = useCallback(() => { + setOpen(false); + }, []); + + // 선택 해제 + const handleClear = useCallback(() => { + const newSelection = null; + setTempSelectedProcurementItem(newSelection); + + if (!showConfirmButtons) { + onProcurementItemSelect?.(newSelection); + setOpen(false); + } + }, [onProcurementItemSelect, showConfirmButtons]); + + return ( + + + + + + + {title} + {description} + + +
+ +
+ + {showConfirmButtons && ( + + + + + )} +
+
+ ); +} diff --git a/components/common/selectors/procurement-item/procurement-item-selector.tsx b/components/common/selectors/procurement-item/procurement-item-selector.tsx new file mode 100644 index 00000000..5650959c --- /dev/null +++ b/components/common/selectors/procurement-item/procurement-item-selector.tsx @@ -0,0 +1,176 @@ +"use client"; + +import React, { useState, useCallback, useEffect } from "react"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Badge } from "@/components/ui/badge"; +import { + Command, + CommandList, + CommandEmpty, + CommandGroup, + CommandItem, +} from "@/components/ui/command"; +import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; +import { Check, ChevronsUpDown, X, Search } from "lucide-react"; +import { cn } from "@/lib/utils"; +import { useDebounce } from "@/hooks/use-debounce"; +import { searchProcurementItemsForSelector, ProcurementSearchItem, getProcurementItemByCode } from "./procurement-item-service"; + +interface ProcurementItemSelectorProps { + selectedProcurementItem?: ProcurementSearchItem | null; + onProcurementItemSelect?: (item: ProcurementSearchItem | null) => void; + onClear?: () => void; + placeholder?: string; + disabled?: boolean; + className?: string; +} + +export function ProcurementItemSelector({ + selectedProcurementItem, + onProcurementItemSelect, + onClear, + placeholder = "품목을 검색하세요...", + disabled = false, + className, +}: ProcurementItemSelectorProps) { + const [open, setOpen] = useState(false); + const [searchQuery, setSearchQuery] = useState(""); + const [searchResults, setSearchResults] = useState([]); + const [isLoading, setIsLoading] = useState(false); + + const debouncedSearchQuery = useDebounce(searchQuery, 300); + + // 검색 쿼리가 변경될 때마다 검색 실행 + useEffect(() => { + const performSearch = async () => { + if (debouncedSearchQuery.length < 1) { + setSearchResults([]); + return; + } + + setIsLoading(true); + try { + const results = await searchProcurementItemsForSelector(debouncedSearchQuery); + setSearchResults(results); + } catch (error) { + console.error("품목 검색 실패:", error); + setSearchResults([]); + } finally { + setIsLoading(false); + } + }; + + performSearch(); + }, [debouncedSearchQuery]); + + // 품목 선택 핸들러 + const handleSelect = useCallback((item: ProcurementSearchItem) => { + onProcurementItemSelect?.(item); + setOpen(false); + setSearchQuery(""); + setSearchResults([]); + }, [onProcurementItemSelect]); + + // 선택 해제 핸들러 + const handleClear = useCallback(() => { + onProcurementItemSelect?.(null); + onClear?.(); + setSearchQuery(""); + setSearchResults([]); + }, [onProcurementItemSelect, onClear]); + + return ( +
+ + + + + +
+
+ + setSearchQuery(e.target.value)} + className="pl-8" + autoFocus + /> +
+
+ + + + {isLoading ? ( +
+ 검색 중... +
+ ) : searchQuery.length < 1 ? ( +
+ 품목코드 또는 품목명을 입력하세요 +
+ ) : ( +
+ 검색 결과가 없습니다 +
+ )} +
+ + {searchResults.map((item) => ( + handleSelect(item)} + className="cursor-pointer" + > + +
+ {item.itemCode} + {item.itemName} +
+
+ ))} +
+
+
+
+
+ + {/* 선택 해제 버튼 */} + {selectedProcurementItem && ( + + )} +
+ ); +} diff --git a/components/common/selectors/procurement-item/procurement-item-service.ts b/components/common/selectors/procurement-item/procurement-item-service.ts new file mode 100644 index 00000000..8e7b2c12 --- /dev/null +++ b/components/common/selectors/procurement-item/procurement-item-service.ts @@ -0,0 +1,55 @@ +import { searchProcurementItems } from "@/lib/procurement-items/service"; + +/** + * 품목 검색을 위한 인터페이스 + */ +export interface ProcurementSearchItem { + itemCode: string; // 품목코드 + itemName: string; // 품목명 + material?: string; // 재질 + specification?: string; // 규격 + unit?: string; // 단위 + displayText: string; // 표시용 텍스트 (code + " - " + name) +} + +/** + * 품목 검색 함수 + * procurement-items 서비스를 통해 품목을 검색합니다. + */ +export async function searchProcurementItemsForSelector(query: string): Promise { + try { + const results = await searchProcurementItems(query); + + return results.map(item => ({ + itemCode: item.itemCode, + itemName: item.itemName, + displayText: `${item.itemCode} - ${item.itemName}`, + })); + } catch (error) { + console.error("품목 검색 오류:", error); + return []; + } +} + +/** + * 품목코드로 품목 상세 정보 조회 + */ +export async function getProcurementItemByCode(itemCode: string): Promise { + try { + const results = await searchProcurementItems(itemCode); + + const exactMatch = results.find(item => item.itemCode === itemCode); + if (exactMatch) { + return { + itemCode: exactMatch.itemCode, + itemName: exactMatch.itemName, + displayText: `${exactMatch.itemCode} - ${exactMatch.itemName}`, + }; + } + + return null; + } catch (error) { + console.error("품목 상세 조회 오류:", error); + return null; + } +} diff --git a/components/ui/file-upload.tsx b/components/ui/file-upload.tsx new file mode 100644 index 00000000..01f09d48 --- /dev/null +++ b/components/ui/file-upload.tsx @@ -0,0 +1,169 @@ +'use client' + +import * as React from 'react' +import { Upload, X, FileText } from 'lucide-react' +import { Button } from './button' +import { cn } from '@/lib/utils' + +interface FileUploadProps { + value: File[] + onChange: (files: File[]) => void + accept?: Record + maxSize?: number + maxFiles?: number + placeholder?: string + disabled?: boolean + className?: string +} + +export function FileUpload({ + value = [], + onChange, + accept, + maxSize = 10 * 1024 * 1024, // 10MB + maxFiles = 5, + placeholder = '파일을 선택하거나 드래그하세요', + disabled = false, + className +}: FileUploadProps) { + const [isDragOver, setIsDragOver] = React.useState(false) + const fileInputRef = React.useRef(null) + + const handleFileSelect = (files: FileList | null) => { + if (!files || disabled) return + + const fileArray = Array.from(files) + const validFiles = fileArray.filter(file => { + // 파일 크기 검증 + if (file.size > maxSize) { + console.warn(`파일 ${file.name}이(가) 최대 크기(${maxSize / 1024 / 1024}MB)를 초과합니다.`) + return false + } + + // 파일 타입 검증 + if (accept) { + const fileType = file.type + const fileName = file.name.toLowerCase() + const isAccepted = Object.entries(accept).some(([mimeType, extensions]) => { + if (fileType && mimeType !== '*/*') { + return fileType.startsWith(mimeType.split('/')[0]) + } + return extensions.some(ext => fileName.endsWith(ext.toLowerCase())) + }) + + if (!isAccepted) { + console.warn(`파일 ${file.name}이(가) 지원되지 않는 형식입니다.`) + return false + } + } + + return true + }) + + const newFiles = [...value, ...validFiles].slice(0, maxFiles) + onChange(newFiles) + } + + const handleInputChange = (event: React.ChangeEvent) => { + handleFileSelect(event.target.files) + // Reset input value to allow re-uploading the same file + event.target.value = '' + } + + const handleDrop = (event: React.DragEvent) => { + event.preventDefault() + setIsDragOver(false) + handleFileSelect(event.dataTransfer.files) + } + + const handleDragOver = (event: React.DragEvent) => { + event.preventDefault() + if (!disabled) { + setIsDragOver(true) + } + } + + const handleDragLeave = () => { + setIsDragOver(false) + } + + const removeFile = (index: number) => { + const newFiles = value.filter((_, i) => i !== index) + onChange(newFiles) + } + + const formatFileSize = (bytes: number) => { + if (bytes === 0) return '0 Bytes' + const k = 1024 + const sizes = ['Bytes', 'KB', 'MB', 'GB'] + const i = Math.floor(Math.log(bytes) / Math.log(k)) + return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i] + } + + return ( +
+ {/* Drop zone */} +
!disabled && fileInputRef.current?.click()} + > + +

{placeholder}

+

+ 최대 {maxFiles}개 파일, 각 파일 {formatFileSize(maxSize)}까지 +

+
+ + {/* Hidden file input */} + + + {/* File list */} + {value.length > 0 && ( +
+ {value.map((file, index) => ( +
+
+ +
+

{file.name}

+

+ {formatFileSize(file.size)} +

+
+
+ +
+ ))} +
+ )} +
+ ) +} diff --git a/config/menuConfig.ts b/config/menuConfig.ts index 9f704514..4f607aae 100644 --- a/config/menuConfig.ts +++ b/config/menuConfig.ts @@ -114,12 +114,12 @@ export const mainNav: MenuSection[] = [ descriptionKey: 'menu.master_data.pq_criteria_desc', groupKey: 'groups.procurement_info', }, - { - titleKey: 'menu.master_data.project_gtc', - href: '/evcp/project-gtc', - descriptionKey: 'menu.master_data.project_gtc_desc', - groupKey: 'groups.procurement_info', - }, + // { + // titleKey: 'menu.master_data.project_gtc', + // href: '/evcp/project-gtc', + // descriptionKey: 'menu.master_data.project_gtc_desc', + // groupKey: 'groups.procurement_info', + // }, { titleKey: 'menu.master_data.evaluation_target', href: '/evcp/evaluation-target-list', @@ -592,12 +592,12 @@ export const procurementNav: MenuSection[] = [ descriptionKey: "menu.master_data.pq_criteria_desc", groupKey: "groups.procurement_info" }, - { - titleKey: "menu.master_data.project_gtc", - href: "/evcp/project-gtc", - descriptionKey: "menu.master_data.project_gtc_desc", - groupKey: "groups.procurement_info" - }, + // { + // titleKey: "menu.master_data.project_gtc", + // href: "/evcp/project-gtc", + // descriptionKey: "menu.master_data.project_gtc_desc", + // groupKey: "groups.procurement_info" + // }, { titleKey: "menu.master_data.evaluation_target", href: "/evcp/evaluation-target-list", diff --git a/db/schema/bidding.ts b/db/schema/bidding.ts index 18699633..c8382ea6 100644 --- a/db/schema/bidding.ts +++ b/db/schema/bidding.ts @@ -10,6 +10,7 @@ import { decimal, boolean, pgEnum, + jsonb, date, } from 'drizzle-orm/pg-core' import { Vendor, vendors } from './vendors' @@ -100,10 +101,10 @@ export const invitationStatusLabels: Record = { pre_quote_declined: '사전견적 미참여', pre_quote_submitted: '사전견적제출완료', bidding_sent: '입찰 초대 발송', - bidding_accepted: '입찰 참여', - bidding_declined: '입찰 미참여', + bidding_accepted: '응찰', + bidding_declined: '응찰 포기', bidding_cancelled: '응찰 취소', - bidding_submitted: '응찰 완료' + bidding_submitted: '최종 응찰' } // 6. 문서 타입 enum @@ -120,6 +121,7 @@ export const documentTypeEnum = pgEnum('document_type', [ 'spec_document', // SPEC 문서 'evaluation_doc', // 평가 관련 문서 'bid_attachment', // 입찰 첨부파일 + 'selection_result', // 선정결과 첨부파일 'other' // 기타 ]) @@ -214,6 +216,11 @@ export const biddings = pgTable('biddings', { createdAt: timestamp('created_at').defaultNow().notNull(), updatedAt: timestamp('updated_at').defaultNow().notNull(), // 최종수정일 updatedBy: varchar('updated_by', { length: 100 }), // 최종수정자 + + // 개찰 정보 + openedAt: timestamp('opened_at'), // 개찰일 + openedBy: varchar('opened_by', { length: 100 }), // 개찰자 + ANFNR: varchar({length: 50}).unique(), // 원본 ANFNR 추적을 위한 Bidding/RFQ Number (ECC), onConflict target이므로 unique 처리 }) @@ -384,6 +391,9 @@ export const biddingCompanies = pgTable('bidding_companies', { finalQuoteAmount: decimal('final_quote_amount', { precision: 15, scale: 2 }), finalQuoteSubmittedAt: timestamp('final_quote_submitted_at'), isFinalSubmission: boolean('is_final_submission').default(false), // 최종제출 여부 + + // 견적 히스토리 스냅샷 (JSON 배열) + quotationSnapshots: jsonb('quotation_snapshots'), // 응찰 시점의 품목별 견적 데이터 스냅샷 isWinner: boolean('is_winner'), // 낙찰 여부 isAttendingMeeting: boolean('is_attending_meeting'), // 사양설명회 참석 여부 awardRatio: decimal('award_ratio', { precision: 5, scale: 2 }), // 발주비율 @@ -497,7 +507,7 @@ export const biddingDocuments = pgTable('bidding_documents', { export const vendorSelectionResults = pgTable('vendor_selection_results', { id: serial('id').primaryKey(), biddingId: integer('bidding_id').references(() => biddings.id).notNull(), - selectedCompanyId: integer('selected_company_id').references(() => vendors.id).notNull(), + selectedCompanyId: integer('selected_company_id').references(() => vendors.id), // null이면 전체 선정결과 // 선정 사유 및 결과 selectionReason: text('selection_reason').notNull(), // 선정 사유 diff --git a/i18n/locales/ko/menu.json b/i18n/locales/ko/menu.json index 1e4d538a..dd50b9fd 100644 --- a/i18n/locales/ko/menu.json +++ b/i18n/locales/ko/menu.json @@ -182,7 +182,7 @@ "pcr_desc": "PCR 관리", "general_contract": "일반 계약", "general_contract_desc": "일반 계약 관리", - "bid_failure": "유찰입찰", + "bid_failure": "폐찰 및 재입찰", "bid_failure_desc": "유찰된 입찰 내역을 확인하고 재입찰을 진행할 수 있습니다", "bid_receive": "입찰서접수및마감", "bid_receive_desc": "입찰서 접수 현황을 확인하고 개찰을 진행할 수 있습니다", diff --git a/lib/bidding/actions.ts b/lib/bidding/actions.ts index b5736707..d0c7a0cd 100644 --- a/lib/bidding/actions.ts +++ b/lib/bidding/actions.ts @@ -1,7 +1,9 @@ "use server" import db from "@/db/db" -import { eq, and } from "drizzle-orm" +import { eq, and, sql } from "drizzle-orm" +import { getServerSession } from "next-auth/next" +import { authOptions } from "@/app/api/auth/[...nextauth]/route" import { biddings, biddingCompanies, @@ -484,8 +486,15 @@ export async function bidClosureAction( description: string files: File[] }, - userId: string + userId: string | undefined ) { + if (!userId) { + return { + success: false, + error: '사용자 정보가 필요합니다.' + } + } + try { const userName = await getUserNameById(userId) @@ -573,6 +582,62 @@ export async function bidClosureAction( } } +// 유찰취소 액션 +export async function cancelDisposalAction( + biddingId: number, + userId: string +) { + try { + const userName = await getUserNameById(userId) + + return await db.transaction(async (tx) => { + // 1. 입찰 정보 확인 + const [existingBidding] = await tx + .select() + .from(biddings) + .where(eq(biddings.id, biddingId)) + .limit(1) + + if (!existingBidding) { + return { + success: false, + error: '입찰 정보를 찾을 수 없습니다.' + } + } + + // 2. 유찰 또는 폐찰 상태인지 확인 + if (existingBidding.status !== 'bidding_disposal' && existingBidding.status !== 'bid_closure') { + return { + success: false, + error: '유찰 또는 폐찰 상태인 입찰만 취소할 수 있습니다.' + } + } + + // 3. 입찰 상태를 입찰 진행중으로 변경 + await tx + .update(biddings) + .set({ + status: 'evaluation_of_bidding', + updatedAt: new Date(), + updatedBy: userName, + }) + .where(eq(biddings.id, biddingId)) + + return { + success: true, + message: '유찰 취소가 완료되었습니다.' + } + }) + + } catch (error) { + console.error('유찰취소 실패:', error) + return { + success: false, + error: error instanceof Error ? error.message : '유찰취소 중 오류가 발생했습니다.' + } + } +} + // 사용자 이름 조회 헬퍼 함수 async function getUserNameById(userId: string): Promise { try { @@ -588,3 +653,164 @@ async function getUserNameById(userId: string): Promise { return userId } } + +// 조기개찰 액션 +export async function earlyOpenBiddingAction(biddingId: number) { + try { + const session = await getServerSession(authOptions) + if (!session?.user?.name) { + return { success: false, message: '인증이 필요합니다.' } + } + + const userName = session.user.name + + return await db.transaction(async (tx) => { + // 1. 입찰 정보 확인 + const [bidding] = await tx + .select({ + id: biddings.id, + status: biddings.status, + submissionEndDate: biddings.submissionEndDate, + title: biddings.title + }) + .from(biddings) + .where(eq(biddings.id, biddingId)) + .limit(1) + + if (!bidding) { + return { success: false, message: '입찰 정보를 찾을 수 없습니다.' } + } + + // 2. 입찰서 제출기간 내인지 확인 + const now = new Date() + if (bidding.submissionEndDate && now > bidding.submissionEndDate) { + return { success: false, message: '입찰서 제출기간이 종료되었습니다.' } + } + + // 3. 참여 현황 확인 + const [participationStats] = await tx + .select({ + participantExpected: db.$count(biddingCompanies), + participantParticipated: db.$count(biddingCompanies, eq(biddingCompanies.invitationStatus, 'bidding_submitted')), + participantDeclined: db.$count(biddingCompanies, and( + eq(biddingCompanies.invitationStatus, 'bidding_declined'), + eq(biddingCompanies.biddingId, biddingId) + )), + participantPending: db.$count(biddingCompanies, and( + eq(biddingCompanies.invitationStatus, 'pending'), + eq(biddingCompanies.biddingId, biddingId) + )), + }) + .from(biddingCompanies) + .where(eq(biddingCompanies.biddingId, biddingId)) + + // 실제 SQL 쿼리로 변경 + const [stats] = await tx + .select({ + participantExpected: sql`COUNT(*)`.as('participant_expected'), + participantParticipated: sql`COUNT(CASE WHEN invitation_status = 'bidding_submitted' THEN 1 END)`.as('participant_participated'), + participantDeclined: sql`COUNT(CASE WHEN invitation_status IN ('bidding_declined', 'bidding_cancelled') THEN 1 END)`.as('participant_declined'), + participantPending: sql`COUNT(CASE WHEN invitation_status IN ('pending', 'bidding_sent', 'bidding_accepted') THEN 1 END)`.as('participant_pending'), + }) + .from(biddingCompanies) + .where(eq(biddingCompanies.biddingId, biddingId)) + + const participantExpected = Number(stats.participantExpected) || 0 + const participantParticipated = Number(stats.participantParticipated) || 0 + const participantDeclined = Number(stats.participantDeclined) || 0 + const participantPending = Number(stats.participantPending) || 0 + + // 4. 조기개찰 조건 검증 + // - 미제출 협력사 = 0 + if (participantPending > 0) { + return { success: false, message: `미제출 협력사가 ${participantPending}명 있어 조기개찰이 불가능합니다.` } + } + + // - 참여협력사 + 포기협력사 = 참여예정협력사 + if (participantParticipated + participantDeclined !== participantExpected) { + return { success: false, message: '모든 협력사가 참여 또는 포기하지 않아 조기개찰이 불가능합니다.' } + } + + // 5. 참여협력사 중 최종응찰 버튼을 클릭한 업체들만 있는지 검증 + // bidding_submitted 상태인 업체들이 있는지 확인 (이미 위에서 검증됨) + + // 6. 조기개찰 상태로 변경 + await tx + .update(biddings) + .set({ + status: 'evaluation_of_bidding', + openedAt: new Date(), + openedBy: userName, + updatedAt: new Date(), + updatedBy: userName, + }) + .where(eq(biddings.id, biddingId)) + + return { success: true, message: '조기개찰이 완료되었습니다.' } + }) + + } catch (error) { + console.error('조기개찰 실패:', error) + return { + success: false, + message: error instanceof Error ? error.message : '조기개찰 중 오류가 발생했습니다.' + } + } +} + +// 개찰 액션 +export async function openBiddingAction(biddingId: number) { + try { + const session = await getServerSession(authOptions) + if (!session?.user?.name) { + return { success: false, message: '인증이 필요합니다.' } + } + + const userName = session.user.name + + return await db.transaction(async (tx) => { + // 1. 입찰 정보 확인 + const [bidding] = await tx + .select({ + id: biddings.id, + status: biddings.status, + submissionEndDate: biddings.submissionEndDate, + title: biddings.title + }) + .from(biddings) + .where(eq(biddings.id, biddingId)) + .limit(1) + + if (!bidding) { + return { success: false, message: '입찰 정보를 찾을 수 없습니다.' } + } + + // 2. 입찰서 제출기간이 종료되었는지 확인 + const now = new Date() + if (bidding.submissionEndDate && now <= bidding.submissionEndDate) { + return { success: false, message: '입찰서 제출기간이 아직 종료되지 않았습니다.' } + } + + // 3. 입찰평가중 상태로 변경 + await tx + .update(biddings) + .set({ + status: 'evaluation_of_bidding', + openedAt: new Date(), + openedBy: userName, + updatedAt: new Date(), + updatedBy: userName, + }) + .where(eq(biddings.id, biddingId)) + + return { success: true, message: '개찰이 완료되었습니다.' } + }) + + } catch (error) { + console.error('개찰 실패:', error) + return { + success: false, + message: error instanceof Error ? error.message : '개찰 중 오류가 발생했습니다.' + } + } +} 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 { @@ -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(null) - const [prItemsForDialog, setPrItemsForDialog] = React.useState([]) - - // 입찰공고 관련 state - const [biddingNotice, setBiddingNotice] = React.useState(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 ( -
- {/* 입찰공고 편집 버튼 */} -
-
-

입찰 상세

-

{bidding.title}

-
- setDialogStates(prev => ({ ...prev, biddingNotice: open }))}> - - - - - - 입찰공고 편집 - -
- setDialogStates(prev => ({ ...prev, biddingNotice: false }))} - /> -
-
-
-
- - {/* 최종제출 현황 및 개찰 버튼 */} - {showBidOpeningButtons && ( - - -
-
-
-
- {finalSubmissionStatus.allSubmitted ? ( - - ) : ( - - )} -

최종제출 현황

-
-
- - 최종 제출: {finalSubmissionStatus.submittedCompanies}/{finalSubmissionStatus.totalCompanies}개 업체 - - {finalSubmissionStatus.allSubmitted ? ( - 모든 업체 제출 완료 - ) : ( - 제출 대기 중 - )} -
-
-
- - {/* 개찰 버튼들 */} -
- - -
-
-
-
- )} - - openDialog('targetPrice')} - onOpenSelectionReasonDialog={() => openDialog('selectionReason')} - onViewItemDetails={handleViewItemDetails} - onEdit={undefined} - /> - - closeDialog('items')} - prItems={prItems} - bidding={bidding} - /> - - closeDialog('targetPrice')} - quotationDetails={quotationDetails} - bidding={bidding} - onSuccess={handleRefresh} - /> - - -
- ) -} 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(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 ( - - - - 내정가 산정 - - 입찰번호: {bidding.biddingNumber} - 견적 통계 및 내정가 설정 - - - -
- {/* 사전견적 리스트 */} - {preQuoteData?.quotes && preQuoteData.quotes.length > 0 && ( -
-

사전견적 현황

-
- - - - 업체명 - 사전견적가 - 제출일 - - - - {preQuoteData.quotes.map((quote: any) => ( - - - {quote.vendorName || `업체 ${quote.companyId}`} - - - {formatCurrency(Number(quote.preQuoteAmount))} - - - {quote.submittedAt - ? new Date(quote.submittedAt).toLocaleDateString('ko-KR') - : '-' - } - - - ))} - -
-
-
- )} - - - - - 항목 - - - - - {/* 사전견적 통계 정보 */} - - 사전견적 수 - - {preQuoteData?.quotationCount || 0}개 - - - {preQuoteData?.lowestQuote && ( - - 최저 사전견적가 - - {formatCurrency(preQuoteData.lowestQuote)} - - - )} - {preQuoteData?.highestQuote && ( - - 최고 사전견적가 - - {formatCurrency(preQuoteData.highestQuote)} - - - )} - {preQuoteData?.averageQuote && ( - - 평균 사전견적가 - - {formatCurrency(preQuoteData.averageQuote)} - - - )} - - {/* 입찰 유형 */} - - 입찰 유형 - - {bidding.biddingType || '-'} - - - - {/* 예산 정보 */} - {bidding.budget && ( - - 예산 - - {formatCurrency(Number(bidding.budget))} - - - )} - - {/* 최종 업데이트 시간 */} - {quotationDetails?.lastUpdated && ( - - 최종 업데이트 - - {new Date(quotationDetails.lastUpdated).toLocaleString('ko-KR')} - - - )} - - {/* 내정가 입력 */} - - - - - -
-
- setTargetPrice(Number(e.target.value))} - placeholder="내정가를 입력하세요" - className="flex-1" - /> - -
-
- {targetPrice > 0 ? formatCurrency(targetPrice) : ''} -
- {preQuoteData?.quotationCount === 0 && ( -
- 사전견적 데이터가 없어 자동 산정이 불가능합니다. -
- )} -
-
-
- - {/* 내정가 산정 기준 입력 */} - - - - - -