summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authordujinkim <dujin.kim@dtsolution.co.kr>2025-11-12 10:42:36 +0000
committerdujinkim <dujin.kim@dtsolution.co.kr>2025-11-12 10:42:36 +0000
commit8642ee064ddf96f1db2b948b4cc8bbbd6cfee820 (patch)
tree36bd57d147ba929f1d72918d1fb91ad2c4778624
parent57ea2f740abf1c7933671561cfe0e421fb5ef3fc (diff)
(최겸) 구매 일반계약, 입찰 수정
-rw-r--r--app/[lng]/evcp/(evcp)/(procurement)/bid-failure/page.tsx4
-rw-r--r--app/[lng]/evcp/(evcp)/(procurement)/bid-receive/page.tsx4
-rw-r--r--app/[lng]/evcp/(evcp)/(procurement)/bid-selection/[id]/detail/page.tsx69
-rw-r--r--components/bidding/bidding-round-actions.tsx32
-rw-r--r--components/bidding/manage/bidding-basic-info-editor.tsx39
-rw-r--r--components/bidding/manage/bidding-items-editor.tsx204
-rw-r--r--components/bidding/manage/create-pre-quote-rfq-dialog.tsx2
-rw-r--r--components/common/selectors/procurement-item/procurement-item-selector-dialog-single.tsx168
-rw-r--r--components/common/selectors/procurement-item/procurement-item-selector.tsx176
-rw-r--r--components/common/selectors/procurement-item/procurement-item-service.ts55
-rw-r--r--components/ui/file-upload.tsx169
-rw-r--r--config/menuConfig.ts24
-rw-r--r--db/schema/bidding.ts18
-rw-r--r--i18n/locales/ko/menu.json2
-rw-r--r--lib/bidding/actions.ts230
-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
-rw-r--r--lib/bidding/failure/biddings-closure-dialog.tsx142
-rw-r--r--lib/bidding/failure/biddings-failure-columns.tsx130
-rw-r--r--lib/bidding/failure/biddings-failure-table.tsx266
-rw-r--r--lib/bidding/list/bidding-detail-dialogs.tsx122
-rw-r--r--lib/bidding/list/biddings-table-columns.tsx13
-rw-r--r--lib/bidding/list/biddings-table.tsx21
-rw-r--r--lib/bidding/list/create-bidding-dialog.tsx2
-rw-r--r--lib/bidding/receive/biddings-receive-columns.tsx86
-rw-r--r--lib/bidding/receive/biddings-receive-table.tsx149
-rw-r--r--lib/bidding/selection/actions.ts219
-rw-r--r--lib/bidding/selection/bidding-info-card.tsx96
-rw-r--r--lib/bidding/selection/bidding-selection-detail-content.tsx41
-rw-r--r--lib/bidding/selection/biddings-selection-columns.tsx4
-rw-r--r--lib/bidding/selection/biddings-selection-table.tsx4
-rw-r--r--lib/bidding/selection/selection-result-form.tsx143
-rw-r--r--lib/bidding/selection/vendor-selection-table.tsx66
-rw-r--r--lib/bidding/service.ts96
-rw-r--r--lib/bidding/vendor/partners-bidding-detail.tsx6
-rw-r--r--lib/general-contracts/detail/general-contract-info-header.tsx3
-rw-r--r--lib/general-contracts/main/create-general-contract-dialog.tsx3
-rw-r--r--lib/general-contracts/main/general-contract-update-sheet.tsx3
-rw-r--r--lib/general-contracts/main/general-contracts-table-columns.tsx2
-rw-r--r--lib/general-contracts/main/general-contracts-table.tsx3
-rw-r--r--lib/general-contracts/service.ts3
-rw-r--r--lib/general-contracts/types.ts3
-rw-r--r--lib/users/auth/passwordUtil.ts83
50 files changed, 2963 insertions, 1588 deletions
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({
<div className="flex flex-col gap-4 p-4">
<div className="flex items-center justify-between">
<div>
- <h1 className="text-2xl font-bold tracking-tight">유찰입찰</h1>
+ <h1 className="text-2xl font-bold tracking-tight">폐찰 및 재입찰</h1>
<p className="text-muted-foreground">
유찰된 입찰 내역을 확인하고 재입찰을 진행할 수 있습니다.
</p>
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({
<div className="flex flex-col gap-4 p-4">
<div className="flex items-center justify-between">
<div>
- <h1 className="text-2xl font-bold tracking-tight">입찰서접수및마감</h1>
+ <h1 className="text-2xl font-bold tracking-tight">입찰서 접수 및 마감</h1>
<p className="text-muted-foreground">
입찰서 접수 현황을 확인하고 개찰을 진행할 수 있습니다.
</p>
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 (
+ <div className="container py-6 space-y-6">
+ {/* 헤더 */}
+ <div className="flex justify-between items-center">
+ <div className="flex items-center gap-4">
+ <div>
+ <h1 className="text-3xl font-bold tracking-tight">
+ 입찰선정 상세보기
+ </h1>
+ <p className="text-muted-foreground mt-2">
+ 입찰 No. {bidding.biddingNumber ?? ""} - {bidding.title}
+ </p>
+ </div>
+ </div>
+ <Link href={`/${lng}/evcp/bid-selection`} passHref>
+ <Button variant="outline" className="flex items-center">
+ <ArrowLeft className="mr-2 h-4 w-4" />
+ 입찰선정 목록으로 돌아가기
+ </Button>
+ </Link>
+ </div>
+
+ {/* 입찰선정 상세 콘텐츠 */}
+ <BiddingSelectionDetailContent biddingId={parsedId} bidding={bidding} />
+ </div>
+ )
+}
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<number | null>(null)
+ const [biddingType, setBiddingType] = React.useState<string | null>(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) {
/>
</td>
<td className="border-r px-3 py-2">
- <MaterialGroupSelectorDialogSingle
- triggerLabel={item.materialGroupNumber || "자재그룹 선택"}
- triggerVariant="outline"
- selectedMaterial={item.materialGroupNumber ? {
- materialGroupCode: item.materialGroupNumber,
- materialGroupDescription: item.materialGroupInfo || '',
- displayText: `${item.materialGroupNumber} - ${item.materialGroupInfo || ''}`
- } : null}
- onMaterialSelect={(material) => {
- if (material) {
- updatePRItem(item.id, {
- materialGroupNumber: material.materialGroupCode,
- materialGroupInfo: material.materialGroupDescription
- })
- } else {
- updatePRItem(item.id, {
- materialGroupNumber: '',
- materialGroupInfo: ''
- })
- }
- }}
- title="자재그룹 선택"
- description="자재그룹을 검색하고 선택해주세요."
- />
+ {biddingType === 'equipment' ? (
+ <ProcurementItemSelectorDialogSingle
+ triggerLabel={item.materialGroupNumber || "품목 선택"}
+ triggerVariant="outline"
+ selectedProcurementItem={item.materialGroupNumber ? {
+ itemCode: item.materialGroupNumber,
+ itemName: item.materialGroupInfo || '',
+ displayText: `${item.materialGroupNumber}`
+ } : null}
+ onProcurementItemSelect={(procurementItem) => {
+ if (procurementItem) {
+ updatePRItem(item.id, {
+ materialGroupNumber: procurementItem.itemCode,
+ materialGroupInfo: procurementItem.itemName
+ })
+ } else {
+ updatePRItem(item.id, {
+ materialGroupNumber: '',
+ materialGroupInfo: ''
+ })
+ }
+ }}
+ title="품목 선택"
+ description="품목을 검색하고 선택해주세요."
+ />
+ ) : (
+ <MaterialGroupSelectorDialogSingle
+ triggerLabel={item.materialGroupNumber || "자재그룹 선택"}
+ triggerVariant="outline"
+ selectedMaterial={item.materialGroupNumber ? {
+ materialGroupCode: item.materialGroupNumber,
+ materialGroupDescription: item.materialGroupInfo || '',
+ displayText: `${item.materialGroupNumber}`
+ } : null}
+ onMaterialSelect={(material) => {
+ if (material) {
+ updatePRItem(item.id, {
+ materialGroupNumber: material.materialGroupCode,
+ materialGroupInfo: material.materialGroupDescription
+ })
+ } else {
+ updatePRItem(item.id, {
+ materialGroupNumber: '',
+ materialGroupInfo: ''
+ })
+ }
+ }}
+ title="자재그룹 선택"
+ description="자재그룹을 검색하고 선택해주세요."
+ />
+ )}
</td>
<td className="border-r px-3 py-2">
<Input
@@ -633,7 +687,7 @@ export function BiddingItemsEditor({ biddingId }: BiddingItemsEditorProps) {
selectedMaterial={item.materialNumber ? {
materialCode: item.materialNumber,
materialName: item.materialInfo || '',
- displayText: `${item.materialNumber} - ${item.materialInfo || ''}`
+ displayText: `${item.materialNumber}`
} : null}
onMaterialSelect={(material) => {
if (material) {
@@ -830,7 +884,7 @@ export function BiddingItemsEditor({ biddingId }: BiddingItemsEditorProps) {
>
{item.wbsCode ? (
<span className="truncate">
- {`${item.wbsCode}${item.wbsName ? ` - ${item.wbsName}` : ''}`}
+ {`${item.wbsCode}`}
</span>
) : (
<span className="text-muted-foreground">WBS 코드 선택</span>
@@ -880,7 +934,7 @@ export function BiddingItemsEditor({ biddingId }: BiddingItemsEditorProps) {
>
{item.costCenterCode ? (
<span className="truncate">
- {`${item.costCenterCode}${item.costCenterName ? ` - ${item.costCenterName}` : ''}`}
+ {`${item.costCenterCode}`}
</span>
) : (
<span className="text-muted-foreground">코스트센터 선택</span>
@@ -931,7 +985,7 @@ export function BiddingItemsEditor({ biddingId }: BiddingItemsEditorProps) {
>
{item.glAccountCode ? (
<span className="truncate">
- {`${item.glAccountCode}${item.glAccountName ? ` - ${item.glAccountName}` : ''}`}
+ {`${item.glAccountCode}`}
</span>
) : (
<span className="text-muted-foreground">GL계정 선택</span>
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
+ * <ProcurementItemSelectorDialogSingle
+ * triggerLabel="품목 선택"
+ * selectedProcurementItem={selectedProcurementItem}
+ * onProcurementItemSelect={(item) => {
+ * 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<ProcurementSearchItem | null>(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 (
+ <Dialog open={open} onOpenChange={handleOpenChange}>
+ <DialogTrigger asChild>
+ <Button variant={triggerVariant} size={triggerSize}>
+ {selectedProcurementItem ? (
+ <span className="truncate">
+ {`${selectedProcurementItem.itemCode} - ${selectedProcurementItem.itemName}`}
+ </span>
+ ) : (
+ <span className="text-muted-foreground">{triggerLabel}</span>
+ )}
+ </Button>
+ </DialogTrigger>
+ <DialogContent className="max-w-4xl max-h-[80vh] overflow-hidden flex flex-col">
+ <DialogHeader>
+ <DialogTitle>{title}</DialogTitle>
+ <DialogDescription>{description}</DialogDescription>
+ </DialogHeader>
+
+ <div className="flex-1 overflow-hidden">
+ <ProcurementItemSelector
+ selectedProcurementItem={tempSelectedProcurementItem}
+ onProcurementItemSelect={handleProcurementItemSelect}
+ onClear={handleClear}
+ />
+ </div>
+
+ {showConfirmButtons && (
+ <DialogFooter>
+ <Button variant="outline" onClick={handleCancel}>
+ 취소
+ </Button>
+ <Button onClick={handleConfirm}>
+ 확인
+ </Button>
+ </DialogFooter>
+ )}
+ </DialogContent>
+ </Dialog>
+ );
+}
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<ProcurementSearchItem[]>([]);
+ 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 (
+ <div className={cn("flex items-center space-x-2", className)}>
+ <Popover open={open} onOpenChange={setOpen}>
+ <PopoverTrigger asChild>
+ <Button
+ variant="outline"
+ role="combobox"
+ aria-expanded={open}
+ className="w-full justify-between"
+ disabled={disabled}
+ >
+ {selectedProcurementItem ? (
+ <span className="truncate">
+ {selectedProcurementItem.displayText}
+ </span>
+ ) : (
+ <span className="text-muted-foreground">{placeholder}</span>
+ )}
+ <ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
+ </Button>
+ </PopoverTrigger>
+ <PopoverContent className="w-full p-0" align="start">
+ <div className="p-2">
+ <div className="relative">
+ <Search className="absolute left-2 top-2.5 h-4 w-4 text-muted-foreground" />
+ <Input
+ placeholder="품목코드 또는 품목명으로 검색..."
+ value={searchQuery}
+ onChange={(e) => setSearchQuery(e.target.value)}
+ className="pl-8"
+ autoFocus
+ />
+ </div>
+ </div>
+ <Command>
+ <CommandList>
+ <CommandEmpty>
+ {isLoading ? (
+ <div className="py-6 text-center text-sm text-muted-foreground">
+ 검색 중...
+ </div>
+ ) : searchQuery.length < 1 ? (
+ <div className="py-6 text-center text-sm text-muted-foreground">
+ 품목코드 또는 품목명을 입력하세요
+ </div>
+ ) : (
+ <div className="py-6 text-center text-sm text-muted-foreground">
+ 검색 결과가 없습니다
+ </div>
+ )}
+ </CommandEmpty>
+ <CommandGroup>
+ {searchResults.map((item) => (
+ <CommandItem
+ key={item.itemCode}
+ value={item.itemCode}
+ onSelect={() => handleSelect(item)}
+ className="cursor-pointer"
+ >
+ <Check
+ className={cn(
+ "mr-2 h-4 w-4",
+ selectedProcurementItem?.itemCode === item.itemCode
+ ? "opacity-100"
+ : "opacity-0"
+ )}
+ />
+ <div className="flex flex-col">
+ <span className="font-medium">{item.itemCode}</span>
+ <span className="text-sm text-muted-foreground">{item.itemName}</span>
+ </div>
+ </CommandItem>
+ ))}
+ </CommandGroup>
+ </CommandList>
+ </Command>
+ </PopoverContent>
+ </Popover>
+
+ {/* 선택 해제 버튼 */}
+ {selectedProcurementItem && (
+ <Button
+ variant="ghost"
+ size="sm"
+ onClick={handleClear}
+ disabled={disabled}
+ className="h-8 w-8 p-0"
+ >
+ <X className="h-4 w-4" />
+ </Button>
+ )}
+ </div>
+ );
+}
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<ProcurementSearchItem[]> {
+ 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<ProcurementSearchItem | null> {
+ 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<string, string[]>
+ 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<HTMLInputElement>(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<HTMLInputElement>) => {
+ 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 (
+ <div className={cn('space-y-2', className)}>
+ {/* Drop zone */}
+ <div
+ className={cn(
+ 'border-2 border-dashed rounded-lg p-6 text-center cursor-pointer transition-colors',
+ isDragOver ? 'border-primary bg-primary/5' : 'border-muted-foreground/25',
+ disabled ? 'cursor-not-allowed opacity-50' : 'hover:border-primary/50'
+ )}
+ onDrop={handleDrop}
+ onDragOver={handleDragOver}
+ onDragLeave={handleDragLeave}
+ onClick={() => !disabled && fileInputRef.current?.click()}
+ >
+ <Upload className="mx-auto h-8 w-8 text-muted-foreground mb-2" />
+ <p className="text-sm text-muted-foreground">{placeholder}</p>
+ <p className="text-xs text-muted-foreground mt-1">
+ 최대 {maxFiles}개 파일, 각 파일 {formatFileSize(maxSize)}까지
+ </p>
+ </div>
+
+ {/* Hidden file input */}
+ <input
+ ref={fileInputRef}
+ type="file"
+ multiple
+ accept={accept ? Object.values(accept).flat().join(',') : undefined}
+ onChange={handleInputChange}
+ className="hidden"
+ disabled={disabled}
+ />
+
+ {/* File list */}
+ {value.length > 0 && (
+ <div className="space-y-2">
+ {value.map((file, index) => (
+ <div
+ key={index}
+ className="flex items-center justify-between p-2 bg-muted rounded-md"
+ >
+ <div className="flex items-center space-x-2">
+ <FileText className="h-4 w-4 text-muted-foreground" />
+ <div className="flex-1 min-w-0">
+ <p className="text-sm font-medium truncate">{file.name}</p>
+ <p className="text-xs text-muted-foreground">
+ {formatFileSize(file.size)}
+ </p>
+ </div>
+ </div>
+ <Button
+ variant="ghost"
+ size="sm"
+ onClick={(e) => {
+ e.stopPropagation()
+ removeFile(index)
+ }}
+ disabled={disabled}
+ >
+ <X className="h-4 w-4" />
+ </Button>
+ </div>
+ ))}
+ </div>
+ )}
+ </div>
+ )
+}
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<string, string> = {
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<string> {
try {
@@ -588,3 +653,164 @@ async function getUserNameById(userId: string): Promise<string> {
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<number>`COUNT(*)`.as('participant_expected'),
+ participantParticipated: sql<number>`COUNT(CASE WHEN invitation_status = 'bidding_submitted' THEN 1 END)`.as('participant_participated'),
+ participantDeclined: sql<number>`COUNT(CASE WHEN invitation_status IN ('bidding_declined', 'bidding_cancelled') THEN 1 END)`.as('participant_declined'),
+ participantPending: sql<number>`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<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>
+ )
+}
diff --git a/lib/bidding/failure/biddings-closure-dialog.tsx b/lib/bidding/failure/biddings-closure-dialog.tsx
new file mode 100644
index 00000000..64aba42f
--- /dev/null
+++ b/lib/bidding/failure/biddings-closure-dialog.tsx
@@ -0,0 +1,142 @@
+// 폐찰하기 다이얼로그
+"use client"
+
+import { useState } from "react"
+import { toast } from "sonner"
+import { bidClosureAction } from "@/lib/bidding/actions"
+import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from "@/components/ui/dialog"
+import { Label } from "@/components/ui/label"
+import { Textarea } from "@/components/ui/textarea"
+import { Input } from "@/components/ui/input"
+import { Button } from "@/components/ui/button"
+import { FileXIcon } from "lucide-react"
+
+interface BiddingsClosureDialogProps {
+ open: boolean;
+ onOpenChange: (open: boolean) => void;
+ bidding: {
+ id: number;
+ title: string;
+ biddingNumber: string;
+ } | null;
+ userId: string;
+ onSuccess?: () => void;
+ }
+
+ export function BiddingsClosureDialog({
+ open,
+ onOpenChange,
+ bidding,
+ userId,
+ onSuccess
+ }: BiddingsClosureDialogProps) {
+ const [description, setDescription] = useState('')
+ const [files, setFiles] = useState<File[]>([])
+ const [isSubmitting, setIsSubmitting] = useState(false)
+
+ const handleSubmit = async (e: React.FormEvent) => {
+ e.preventDefault()
+
+ if (!bidding || !description.trim()) {
+ toast.error('폐찰 사유를 입력해주세요.')
+ return
+ }
+
+ setIsSubmitting(true)
+
+ try {
+ const result = await bidClosureAction(bidding.id, {
+ description: description.trim(),
+ files
+ }, userId)
+
+ if (result.success) {
+ toast.success(result.message)
+ onOpenChange(false)
+ onSuccess?.()
+ // 페이지 새로고침 또는 상태 업데이트
+ window.location.reload()
+ } else {
+ toast.error(result.error || '폐찰 처리 중 오류가 발생했습니다.')
+ }
+ } catch (error) {
+ toast.error('폐찰 처리 중 오류가 발생했습니다.')
+ } finally {
+ setIsSubmitting(false)
+ }
+ }
+
+ const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
+ if (e.target.files) {
+ setFiles(Array.from(e.target.files))
+ }
+ }
+
+ if (!bidding) return null
+
+ return (
+ <Dialog open={open} onOpenChange={onOpenChange}>
+ <DialogContent className="max-w-2xl">
+ <DialogHeader>
+ <DialogTitle className="flex items-center gap-2">
+ <FileXIcon className="h-5 w-5 text-destructive" />
+ 폐찰하기
+ </DialogTitle>
+ <DialogDescription>
+ {bidding.title} ({bidding.biddingNumber})를 폐찰합니다.
+ </DialogDescription>
+ </DialogHeader>
+
+ <form onSubmit={handleSubmit} className="space-y-4">
+ <div className="space-y-2">
+ <Label htmlFor="description">폐찰 사유 <span className="text-destructive">*</span></Label>
+ <Textarea
+ id="description"
+ placeholder="폐찰 사유를 입력해주세요..."
+ value={description}
+ onChange={(e) => setDescription(e.target.value)}
+ className="min-h-[100px]"
+ required
+ />
+ </div>
+
+ <div className="space-y-2">
+ <Label htmlFor="files">첨부파일</Label>
+ <Input
+ id="files"
+ type="file"
+ multiple
+ onChange={handleFileChange}
+ className="cursor-pointer"
+ accept=".pdf,.doc,.docx,.xls,.xlsx,.txt,.jpg,.jpeg,.png"
+ />
+ {files.length > 0 && (
+ <div className="text-sm text-muted-foreground">
+ 선택된 파일: {files.map(f => f.name).join(', ')}
+ </div>
+ )}
+ </div>
+
+ <div className="flex justify-end gap-2 pt-4">
+ <Button
+ type="button"
+ variant="outline"
+ onClick={() => onOpenChange(false)}
+ disabled={isSubmitting}
+ >
+ 취소
+ </Button>
+ <Button
+ type="submit"
+ variant="destructive"
+ disabled={isSubmitting || !description.trim()}
+ >
+ {isSubmitting ? '처리 중...' : '폐찰하기'}
+ </Button>
+ </div>
+ </form>
+ </DialogContent>
+ </Dialog>
+ )
+ }
+ \ No newline at end of file
diff --git a/lib/bidding/failure/biddings-failure-columns.tsx b/lib/bidding/failure/biddings-failure-columns.tsx
index 8a888079..3046dbc0 100644
--- a/lib/bidding/failure/biddings-failure-columns.tsx
+++ b/lib/bidding/failure/biddings-failure-columns.tsx
@@ -5,8 +5,9 @@ import { type ColumnDef } from "@tanstack/react-table"
import { Badge } from "@/components/ui/badge"
import { Button } from "@/components/ui/button"
import {
- Eye, Calendar, FileX, DollarSign, AlertTriangle, RefreshCw
+ Eye, Calendar, FileX, DollarSign, AlertTriangle, RefreshCw, FileText
} from "lucide-react"
+import { Checkbox } from "@/components/ui/checkbox"
import {
Tooltip,
TooltipContent,
@@ -27,6 +28,7 @@ import {
} from "@/db/schema"
import { formatDate } from "@/lib/utils"
import { DataTableRowAction } from "@/types/table"
+import { downloadFile } from "@/lib/file-download"
type BiddingFailureItem = {
id: number
@@ -55,6 +57,15 @@ type BiddingFailureItem = {
disposalUpdatedAt: Date | null // 폐찰수정일
disposalUpdatedBy: string | null // 폐찰수정자
+ // 폐찰 정보
+ closureReason: string | null // 폐찰사유
+ closureDocuments: {
+ id: number
+ fileName: string
+ originalFileName: string
+ filePath: string
+ }[] // 폐찰 첨부파일들
+
// 기타 정보
createdBy: string | null
createdAt: Date | null
@@ -94,6 +105,25 @@ const formatCurrency = (amount: string | number | null, currency = 'KRW') => {
export function getBiddingsFailureColumns({ setRowAction }: GetColumnsProps): ColumnDef<BiddingFailureItem>[] {
return [
+ // ░░░ 선택 ░░░
+ {
+ id: "select",
+ header: "",
+ cell: ({ row }) => (
+ <Checkbox
+ checked={row.getIsSelected()}
+ onCheckedChange={(value) => {
+ // single select 모드에서는 다른 행들의 선택을 해제
+ row.toggleSelected(!!value)
+ }}
+ aria-label="행 선택"
+ />
+ ),
+ size: 50,
+ enableSorting: false,
+ enableHiding: false,
+ },
+
// ░░░ 입찰번호 ░░░
{
accessorKey: "biddingNumber",
@@ -188,7 +218,7 @@ export function getBiddingsFailureColumns({ setRowAction }: GetColumnsProps): Co
accessorKey: "biddingRegistrationDate",
header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="입찰등록일" />,
cell: ({ row }) => (
- <span className="text-sm">{formatDate(row.original.biddingRegistrationDate, "KR")}</span>
+ <span className="text-sm">{row.original.biddingRegistrationDate ? formatDate(row.original.biddingRegistrationDate, "KR") : '-'}</span>
),
size: 100,
meta: { excelHeader: "입찰등록일" },
@@ -216,7 +246,7 @@ export function getBiddingsFailureColumns({ setRowAction }: GetColumnsProps): Co
cell: ({ row }) => (
<div className="flex items-center gap-1">
<AlertTriangle className="h-4 w-4 text-red-500" />
- <span className="text-sm">{formatDate(row.original.disposalDate, "KR")}</span>
+ <span className="text-sm">{row.original.disposalDate ? formatDate(row.original.disposalDate, "KR") : '-'}</span>
</div>
),
size: 100,
@@ -230,7 +260,7 @@ export function getBiddingsFailureColumns({ setRowAction }: GetColumnsProps): Co
cell: ({ row }) => (
<div className="flex items-center gap-1">
<FileX className="h-4 w-4 text-red-500" />
- <span className="text-sm">{formatDate(row.original.disposalUpdatedAt, "KR")}</span>
+ <span className="text-sm">{row.original.disposalUpdatedAt ? formatDate(row.original.disposalUpdatedAt, "KR") : '-'}</span>
</div>
),
size: 100,
@@ -248,6 +278,57 @@ export function getBiddingsFailureColumns({ setRowAction }: GetColumnsProps): Co
meta: { excelHeader: "폐찰수정자" },
},
+ // ░░░ 폐찰사유 ░░░
+ {
+ id: "closureReason",
+ header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="폐찰사유" />,
+ cell: ({ row }) => (
+ <div className="max-w-[200px] truncate" title={row.original.closureReason || undefined}>
+ <span className="text-sm">{row.original.closureReason || '-'}</span>
+ </div>
+ ),
+ size: 150,
+ meta: { excelHeader: "폐찰사유" },
+ },
+
+ // ░░░ 폐찰첨부파일 ░░░
+ {
+ id: "closureDocuments",
+ header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="폐찰첨부파일" />,
+ cell: ({ row }) => {
+ const documents = row.original.closureDocuments || []
+
+ if (documents.length === 0) {
+ return <span className="text-sm text-muted-foreground">-</span>
+ }
+
+ return (
+ <div className="flex flex-wrap gap-1 max-w-[200px]">
+ {documents.map((doc) => (
+ <Button
+ key={doc.id}
+ variant="link"
+ size="sm"
+ className="p-0 h-auto text-xs underline"
+ onClick={async () => {
+ try {
+ await downloadFile(doc.filePath, doc.originalFileName)
+ } catch (error) {
+ console.error('파일 다운로드 실패:', error)
+ }
+ }}
+ >
+ <FileText className="mr-1 h-3 w-3" />
+ {doc.originalFileName}
+ </Button>
+ ))}
+ </div>
+ )
+ },
+ size: 200,
+ meta: { excelHeader: "폐찰첨부파일" },
+ },
+
// ░░░ P/R번호 ░░░
{
accessorKey: "prNumber",
@@ -267,7 +348,7 @@ export function getBiddingsFailureColumns({ setRowAction }: GetColumnsProps): Co
<span className="text-sm">{row.original.createdBy || '-'}</span>
),
size: 100,
- meta: { excelHeader: "등록자" },
+ meta: { excelHeader: "최종수정자" },
},
// ░░░ 등록일시 ░░░
@@ -275,46 +356,11 @@ export function getBiddingsFailureColumns({ setRowAction }: GetColumnsProps): Co
accessorKey: "createdAt",
header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="등록일시" />,
cell: ({ row }) => (
- <span className="text-sm">{formatDate(row.original.createdAt, "KR")}</span>
+ <span className="text-sm">{row.original.createdAt ? formatDate(row.original.createdAt, "KR") : '-'}</span>
),
size: 100,
- meta: { excelHeader: "등록일시" },
+ meta: { excelHeader: "최종일시" },
},
- // ═══════════════════════════════════════════════════════════════
- // 액션
- // ═══════════════════════════════════════════════════════════════
- {
- id: "actions",
- header: "액션",
- cell: ({ row }) => (
- <DropdownMenu>
- <DropdownMenuTrigger asChild>
- <Button variant="ghost" className="h-8 w-8 p-0">
- <span className="sr-only">메뉴 열기</span>
- <FileX className="h-4 w-4" />
- </Button>
- </DropdownMenuTrigger>
- <DropdownMenuContent align="end">
- <DropdownMenuItem onClick={() => setRowAction({ row, type: "view" })}>
- <Eye className="mr-2 h-4 w-4" />
- 상세보기
- </DropdownMenuItem>
- <DropdownMenuItem onClick={() => setRowAction({ row, type: "history" })}>
- <Calendar className="mr-2 h-4 w-4" />
- 이력보기
- </DropdownMenuItem>
- <DropdownMenuSeparator />
- <DropdownMenuItem onClick={() => setRowAction({ row, type: "rebid" })}>
- <RefreshCw className="mr-2 h-4 w-4" />
- 재입찰
- </DropdownMenuItem>
- </DropdownMenuContent>
- </DropdownMenu>
- ),
- size: 50,
- enableSorting: false,
- enableHiding: false,
- },
]
}
diff --git a/lib/bidding/failure/biddings-failure-table.tsx b/lib/bidding/failure/biddings-failure-table.tsx
index 901648d2..c80021ea 100644
--- a/lib/bidding/failure/biddings-failure-table.tsx
+++ b/lib/bidding/failure/biddings-failure-table.tsx
@@ -18,7 +18,12 @@ import {
biddingStatusLabels,
contractTypeLabels,
} from "@/db/schema"
-import { SpecificationMeetingDialog, PrDocumentsDialog } from "../list/bidding-detail-dialogs"
+import { BiddingsClosureDialog } from "./biddings-closure-dialog"
+import { Button } from "@/components/ui/button"
+import { FileX, RefreshCw, Undo2 } from "lucide-react"
+import { bidClosureAction, cancelDisposalAction } from "@/lib/bidding/actions"
+import { increaseRoundOrRebid } from "@/lib/bidding/service"
+import { useToast } from "@/hooks/use-toast"
type BiddingFailureItem = {
id: number
@@ -30,7 +35,7 @@ type BiddingFailureItem = {
prNumber: string | null
// 가격 정보
- targetPrice: number | null
+ targetPrice: string | number | null
currency: string | null
// 일정 정보
@@ -47,6 +52,15 @@ type BiddingFailureItem = {
disposalUpdatedAt: Date | null // 폐찰수정일
disposalUpdatedBy: string | null // 폐찰수정자
+ // 폐찰 정보
+ closureReason: string | null // 폐찰사유
+ closureDocuments: {
+ id: number
+ fileName: string
+ originalFileName: string
+ filePath: string
+ }[] // 폐찰 첨부파일들
+
// 기타 정보
createdBy: string | null
createdAt: Date | null
@@ -69,9 +83,9 @@ export function BiddingsFailureTable({ promises }: BiddingsFailureTableProps) {
const { data, pageCount } = biddingsResult
const [isCompact, setIsCompact] = React.useState<boolean>(false)
- const [specMeetingDialogOpen, setSpecMeetingDialogOpen] = React.useState(false)
- const [prDocumentsDialogOpen, setPrDocumentsDialogOpen] = React.useState(false)
+ const [biddingClosureDialogOpen, setBiddingClosureDialogOpen] = React.useState(false)
const [selectedBidding, setSelectedBidding] = React.useState<BiddingFailureItem | null>(null)
+ const { toast } = useToast()
const [rowAction, setRowAction] = React.useState<DataTableRowAction<BiddingFailureItem> | null>(null)
@@ -89,17 +103,18 @@ export function BiddingsFailureTable({ promises }: BiddingsFailureTableProps) {
setSelectedBidding(rowAction.row.original)
switch (rowAction.type) {
- case "view":
- // 상세 페이지로 이동
- router.push(`/evcp/bid/${rowAction.row.original.id}`)
+ case "rebid":
+ // 재입찰
+ handleRebid(rowAction.row.original)
break
- case "history":
- // 이력보기 (추후 구현)
- console.log('이력보기:', rowAction.row.original)
+ case "closure":
+ // 폐찰
+ setSelectedBidding(rowAction.row.original)
+ setBiddingClosureDialogOpen(true)
break
- case "rebid":
- // 재입찰 (추후 구현)
- console.log('재입찰:', rowAction.row.original)
+ case "cancelDisposal":
+ // 유찰취소
+ handleCancelDisposal(rowAction.row.original)
break
default:
break
@@ -163,6 +178,8 @@ export function BiddingsFailureTable({ promises }: BiddingsFailureTableProps) {
filterFields,
enablePinning: true,
enableAdvancedFilter: true,
+ enableRowSelection: true,
+ singleRowSelection: true,
initialState: {
sorting: [{ id: "disposalDate", desc: true }], // 유찰일 기준 최신순
columnPinning: { right: ["actions"] },
@@ -176,17 +193,85 @@ export function BiddingsFailureTable({ promises }: BiddingsFailureTableProps) {
setIsCompact(compact)
}, [])
- const handleSpecMeetingDialogClose = React.useCallback(() => {
- setSpecMeetingDialogOpen(false)
+ const handleBiddingClosureDialogClose = React.useCallback(() => {
+ setBiddingClosureDialogOpen(false)
setRowAction(null)
setSelectedBidding(null)
}, [])
- const handlePrDocumentsDialogClose = React.useCallback(() => {
- setPrDocumentsDialogOpen(false)
- setRowAction(null)
- setSelectedBidding(null)
- }, [])
+ const handleRebid = React.useCallback(async (bidding: BiddingFailureItem) => {
+ if (!session?.user?.id) {
+ toast({
+ title: "오류",
+ description: "사용자 정보를 찾을 수 없습니다.",
+ variant: "destructive",
+ })
+ return
+ }
+
+ try {
+ const result = await increaseRoundOrRebid(bidding.id, session.user.id, 'rebidding')
+
+ if (result.success) {
+ toast({
+ title: "성공",
+ description: result.message,
+ })
+ // 페이지 새로고침
+ router.refresh()
+ } else {
+ toast({
+ title: "오류",
+ description: result.error || "재입찰 중 오류가 발생했습니다.",
+ variant: "destructive",
+ })
+ }
+ } catch (error) {
+ console.error('재입찰 실패:', error)
+ toast({
+ title: "오류",
+ description: "재입찰 중 오류가 발생했습니다.",
+ variant: "destructive",
+ })
+ }
+ }, [session?.user?.id, toast, router])
+
+ const handleCancelDisposal = React.useCallback(async (bidding: BiddingFailureItem) => {
+ if (!session?.user?.id) {
+ toast({
+ title: "오류",
+ description: "사용자 정보를 찾을 수 없습니다.",
+ variant: "destructive",
+ })
+ return
+ }
+
+ try {
+ const result = await cancelDisposalAction(bidding.id, session.user.id)
+
+ if (result.success) {
+ toast({
+ title: "성공",
+ description: result.message,
+ })
+ // 페이지 새로고침
+ router.refresh()
+ } else {
+ toast({
+ title: "오류",
+ description: result.error || "유찰취소 중 오류가 발생했습니다.",
+ variant: "destructive",
+ })
+ }
+ } catch (error) {
+ console.error('유찰취소 실패:', error)
+ toast({
+ title: "오류",
+ description: "유찰취소 중 오류가 발생했습니다.",
+ variant: "destructive",
+ })
+ }
+ }, [session?.user?.id, toast, router])
return (
<>
@@ -202,22 +287,137 @@ export function BiddingsFailureTable({ promises }: BiddingsFailureTableProps) {
compactStorageKey="biddingsFailureTableCompact"
onCompactChange={handleCompactChange}
>
+ {/* Toolbar 액션 버튼들 */}
+ <div className="flex items-center gap-2">
+ <Button
+ variant="outline"
+ size="sm"
+ onClick={() => {
+ const selectedRows = table.getFilteredSelectedRowModel().rows
+ if (selectedRows.length === 0) {
+ toast({
+ title: "선택 필요",
+ description: "폐찰할 입찰을 선택해주세요.",
+ variant: "destructive",
+ })
+ return
+ }
+ if (selectedRows.length > 1) {
+ toast({
+ title: "하나만 선택",
+ description: "폐찰은 한 개의 입찰만 선택해주세요.",
+ variant: "destructive",
+ })
+ return
+ }
+ const bidding = selectedRows[0].original
+ if (bidding.status !== 'bidding_disposal') {
+ toast({
+ title: "유찰 상태만 가능",
+ description: "유찰 상태인 입찰만 폐찰할 수 있습니다.",
+ variant: "destructive",
+ })
+ return
+ }
+ setSelectedBidding(bidding)
+ setBiddingClosureDialogOpen(true)
+ }}
+ disabled={table.getFilteredSelectedRowModel().rows.length !== 1 ||
+ (table.getFilteredSelectedRowModel().rows.length === 1 &&
+ table.getFilteredSelectedRowModel().rows[0].original.status === 'bid_closure')}
+ >
+ <FileX className="mr-2 h-4 w-4" />
+ 폐찰
+ </Button>
+
+ <Button
+ variant="outline"
+ size="sm"
+ onClick={() => {
+ const selectedRows = table.getFilteredSelectedRowModel().rows
+ if (selectedRows.length === 0) {
+ toast({
+ title: "선택 필요",
+ description: "재입찰할 입찰을 선택해주세요.",
+ variant: "destructive",
+ })
+ return
+ }
+ if (selectedRows.length > 1) {
+ toast({
+ title: "하나만 선택",
+ description: "재입찰은 한 개의 입찰만 선택해주세요.",
+ variant: "destructive",
+ })
+ return
+ }
+ const bidding = selectedRows[0].original
+ handleRebid(bidding)
+ }}
+ disabled={table.getFilteredSelectedRowModel().rows.length !== 1 ||
+ (table.getFilteredSelectedRowModel().rows.length === 1 &&
+ table.getFilteredSelectedRowModel().rows[0].original.status === 'bid_closure')}
+ >
+ <RefreshCw className="mr-2 h-4 w-4" />
+ 재입찰
+ </Button>
+
+ <Button
+ variant="outline"
+ size="sm"
+ onClick={() => {
+ const selectedRows = table.getFilteredSelectedRowModel().rows
+ if (selectedRows.length === 0) {
+ toast({
+ title: "선택 필요",
+ description: "유찰취소할 입찰을 선택해주세요.",
+ variant: "destructive",
+ })
+ return
+ }
+ if (selectedRows.length > 1) {
+ toast({
+ title: "하나만 선택",
+ description: "유찰취소는 한 개의 입찰만 선택해주세요.",
+ variant: "destructive",
+ })
+ return
+ }
+ const bidding = selectedRows[0].original
+ if (bidding.status !== 'bidding_disposal' && bidding.status !== 'bid_closure') {
+ toast({
+ title: "유찰/폐찰 상태만 가능",
+ description: "유찰 또는 폐찰 상태인 입찰만 취소할 수 있습니다.",
+ variant: "destructive",
+ })
+ return
+ }
+ handleCancelDisposal(bidding)
+ }}
+ disabled={table.getFilteredSelectedRowModel().rows.length !== 1 ||
+ (table.getFilteredSelectedRowModel().rows.length === 1 &&
+ table.getFilteredSelectedRowModel().rows[0].original.status === 'bid_closure')}
+ >
+ <Undo2 className="mr-2 h-4 w-4" />
+ 유찰취소
+ </Button>
+ </div>
</DataTableAdvancedToolbar>
</DataTable>
- {/* 사양설명회 다이얼로그 */}
- <SpecificationMeetingDialog
- open={specMeetingDialogOpen}
- onOpenChange={handleSpecMeetingDialogClose}
- bidding={selectedBidding}
- />
-
- {/* PR 문서 다이얼로그 */}
- <PrDocumentsDialog
- open={prDocumentsDialogOpen}
- onOpenChange={handlePrDocumentsDialogClose}
- bidding={selectedBidding}
- />
+ {/* 폐찰 다이얼로그 */}
+ {selectedBidding && session?.user?.id && (
+ <BidClosureDialog
+ open={biddingClosureDialogOpen}
+ onOpenChange={handleBiddingClosureDialogClose}
+ bidding={selectedBidding}
+ userId={session.user.id}
+ onSuccess={() => {
+ router.refresh()
+ handleBiddingClosureDialogClose()
+ }}
+ />
+ )}
</>
)
}
diff --git a/lib/bidding/list/bidding-detail-dialogs.tsx b/lib/bidding/list/bidding-detail-dialogs.tsx
index 065000ce..c7045c51 100644
--- a/lib/bidding/list/bidding-detail-dialogs.tsx
+++ b/lib/bidding/list/bidding-detail-dialogs.tsx
@@ -359,128 +359,6 @@ export function SpecificationMeetingDialog({
// PR 문서 다이얼로그는 bidding-pr-documents-dialog.tsx로 이동됨
// import { PrDocumentsDialog } from './bidding-pr-documents-dialog'로 사용하세요
-// 폐찰하기 다이얼로그
-interface BidClosureDialogProps {
- open: boolean;
- onOpenChange: (open: boolean) => void;
- bidding: BiddingListItem | null;
- userId: string;
-}
-
-export function BidClosureDialog({
- open,
- onOpenChange,
- bidding,
- userId
-}: BidClosureDialogProps) {
- const [description, setDescription] = useState('')
- const [files, setFiles] = useState<File[]>([])
- const [isSubmitting, setIsSubmitting] = useState(false)
-
- const handleSubmit = async (e: React.FormEvent) => {
- e.preventDefault()
-
- if (!bidding || !description.trim()) {
- toast.error('폐찰 사유를 입력해주세요.')
- return
- }
-
- setIsSubmitting(true)
-
- try {
- const result = await bidClosureAction(bidding.id, {
- description: description.trim(),
- files
- }, userId)
-
- if (result.success) {
- toast.success(result.message)
- onOpenChange(false)
- // 페이지 새로고침 또는 상태 업데이트
- window.location.reload()
- } else {
- toast.error(result.error || '폐찰 처리 중 오류가 발생했습니다.')
- }
- } catch (error) {
- toast.error('폐찰 처리 중 오류가 발생했습니다.')
- } finally {
- setIsSubmitting(false)
- }
- }
-
- const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
- if (e.target.files) {
- setFiles(Array.from(e.target.files))
- }
- }
-
- if (!bidding) return null
-
- return (
- <Dialog open={open} onOpenChange={onOpenChange}>
- <DialogContent className="max-w-2xl">
- <DialogHeader>
- <DialogTitle className="flex items-center gap-2">
- <FileXIcon className="h-5 w-5 text-destructive" />
- 폐찰하기
- </DialogTitle>
- <DialogDescription>
- {bidding.title} ({bidding.biddingNumber})를 폐찰합니다.
- </DialogDescription>
- </DialogHeader>
-
- <form onSubmit={handleSubmit} className="space-y-4">
- <div className="space-y-2">
- <Label htmlFor="description">폐찰 사유 <span className="text-destructive">*</span></Label>
- <Textarea
- id="description"
- placeholder="폐찰 사유를 입력해주세요..."
- value={description}
- onChange={(e) => setDescription(e.target.value)}
- className="min-h-[100px]"
- required
- />
- </div>
-
- <div className="space-y-2">
- <Label htmlFor="files">첨부파일</Label>
- <Input
- id="files"
- type="file"
- multiple
- onChange={handleFileChange}
- className="cursor-pointer"
- accept=".pdf,.doc,.docx,.xls,.xlsx,.txt,.jpg,.jpeg,.png"
- />
- {files.length > 0 && (
- <div className="text-sm text-muted-foreground">
- 선택된 파일: {files.map(f => f.name).join(', ')}
- </div>
- )}
- </div>
-
- <div className="flex justify-end gap-2 pt-4">
- <Button
- type="button"
- variant="outline"
- onClick={() => onOpenChange(false)}
- disabled={isSubmitting}
- >
- 취소
- </Button>
- <Button
- type="submit"
- variant="destructive"
- disabled={isSubmitting || !description.trim()}
- >
- {isSubmitting ? '처리 중...' : '폐찰하기'}
- </Button>
- </div>
- </form>
- </DialogContent>
- </Dialog>
- )
-}
// Re-export for backward compatibility
export { PrDocumentsDialog } from './bidding-pr-documents-dialog' \ No newline at end of file
diff --git a/lib/bidding/list/biddings-table-columns.tsx b/lib/bidding/list/biddings-table-columns.tsx
index 10966e0e..f5e77d03 100644
--- a/lib/bidding/list/biddings-table-columns.tsx
+++ b/lib/bidding/list/biddings-table-columns.tsx
@@ -136,7 +136,7 @@ export function getBiddingsColumns({ setRowAction }: GetColumnsProps): ColumnDef
header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="입찰명" />,
cell: ({ row }) => (
<div className="truncate max-w-[200px]" title={row.original.title}>
- <Button
+ {/* <Button
variant="link"
className="p-0 h-auto text-left justify-start font-bold underline"
onClick={() => setRowAction({ row, type: "view" })}
@@ -144,7 +144,8 @@ export function getBiddingsColumns({ setRowAction }: GetColumnsProps): ColumnDef
<div className="whitespace-pre-line">
{row.original.title}
</div>
- </Button>
+ </Button> */}
+ {row.original.title}
</div>
),
size: 200,
@@ -389,7 +390,7 @@ export function getBiddingsColumns({ setRowAction }: GetColumnsProps): ColumnDef
<Eye className="mr-2 h-4 w-4" />
상세보기
</DropdownMenuItem>
- <DropdownMenuItem
+ {/* <DropdownMenuItem
onClick={() => setRowAction({ row, type: "update" })}
disabled={['bidding_opened', 'bidding_closed', 'vendor_selected'].includes(row.original.status)}
>
@@ -398,8 +399,8 @@ export function getBiddingsColumns({ setRowAction }: GetColumnsProps): ColumnDef
{['bidding_opened', 'bidding_closed', 'vendor_selected'].includes(row.original.status) && (
<span className="text-xs text-muted-foreground ml-2">(수정 불가)</span>
)}
- </DropdownMenuItem>
- <DropdownMenuSeparator />
+ </DropdownMenuItem> */}
+ {/* <DropdownMenuSeparator />
<DropdownMenuItem
onClick={() => setRowAction({ row, type: "bid_closure" })}
disabled={row.original.status !== 'bidding_disposal'}
@@ -409,7 +410,7 @@ export function getBiddingsColumns({ setRowAction }: GetColumnsProps): ColumnDef
{row.original.status !== 'bidding_disposal' && (
<span className="text-xs text-muted-foreground ml-2">(유찰 시에만 가능)</span>
)}
- </DropdownMenuItem>
+ </DropdownMenuItem> */}
{/* <DropdownMenuSeparator />
<DropdownMenuItem onClick={() => setRowAction({ row, type: "copy" })}>
<Package className="mr-2 h-4 w-4" />
diff --git a/lib/bidding/list/biddings-table.tsx b/lib/bidding/list/biddings-table.tsx
index 39952d5a..89b6260c 100644
--- a/lib/bidding/list/biddings-table.tsx
+++ b/lib/bidding/list/biddings-table.tsx
@@ -23,8 +23,7 @@ import {
biddingTypeLabels
} from "@/db/schema"
import { EditBiddingSheet } from "./edit-bidding-sheet"
-import { SpecificationMeetingDialog, PrDocumentsDialog, BidClosureDialog } from "./bidding-detail-dialogs"
-
+import { SpecificationMeetingDialog, PrDocumentsDialog } from "./bidding-detail-dialogs"
interface BiddingsTableProps {
promises: Promise<
@@ -44,7 +43,6 @@ export function BiddingsTable({ promises }: BiddingsTableProps) {
const [isCompact, setIsCompact] = React.useState<boolean>(false)
const [specMeetingDialogOpen, setSpecMeetingDialogOpen] = React.useState(false)
const [prDocumentsDialogOpen, setPrDocumentsDialogOpen] = React.useState(false)
- const [bidClosureDialogOpen, setBidClosureDialogOpen] = React.useState(false)
const [selectedBidding, setSelectedBidding] = React.useState<BiddingListItemWithManagerCode | null>(null)
console.log(data,"data")
@@ -78,10 +76,6 @@ export function BiddingsTable({ promises }: BiddingsTableProps) {
case "pr_documents":
setPrDocumentsDialogOpen(true)
break
- case "bid_closure":
- setBidClosureDialogOpen(true)
- break
- // 기존 다른 액션들은 그대로 유지
default:
break
}
@@ -160,12 +154,6 @@ export function BiddingsTable({ promises }: BiddingsTableProps) {
setSelectedBidding(null)
}, [])
- const handleBidClosureDialogClose = React.useCallback(() => {
- setBidClosureDialogOpen(false)
- setRowAction(null)
- setSelectedBidding(null)
- }, [])
-
return (
<>
@@ -208,13 +196,6 @@ export function BiddingsTable({ promises }: BiddingsTableProps) {
bidding={selectedBidding}
/>
- {/* 폐찰하기 다이얼로그 */}
- <BidClosureDialog
- open={bidClosureDialogOpen}
- onOpenChange={handleBidClosureDialogClose}
- bidding={selectedBidding}
- userId={session?.user?.id ? String(session.user.id) : ''}
- />
</>
)
diff --git a/lib/bidding/list/create-bidding-dialog.tsx b/lib/bidding/list/create-bidding-dialog.tsx
index 20ea740f..ff68e739 100644
--- a/lib/bidding/list/create-bidding-dialog.tsx
+++ b/lib/bidding/list/create-bidding-dialog.tsx
@@ -62,8 +62,6 @@ import { createBiddingSchema, type CreateBiddingSchema } from '@/lib/bidding/val
import { contractTypeLabels, biddingTypeLabels, awardCountLabels } from '@/db/schema'
import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle } from '@/components/ui/alert-dialog'
import { cn } from '@/lib/utils'
-import { MaterialGroupSingleSelector } from '@/components/common/material/material-group-single-selector'
-import { MaterialSingleSelector } from '@/components/common/selectors/material/material-single-selector'
import { PurchaseGroupCodeSelector } from '@/components/common/selectors/purchase-group-code/purchase-group-code-selector'
import { ProcurementManagerSelector } from '@/components/common/selectors/procurement-manager/procurement-manager-selector'
import { WbsCodeSingleSelector } from '@/components/common/selectors/wbs-code/wbs-code-single-selector'
diff --git a/lib/bidding/receive/biddings-receive-columns.tsx b/lib/bidding/receive/biddings-receive-columns.tsx
index 724a7396..d5798782 100644
--- a/lib/bidding/receive/biddings-receive-columns.tsx
+++ b/lib/bidding/receive/biddings-receive-columns.tsx
@@ -4,6 +4,7 @@ import * as React from "react"
import { type ColumnDef } from "@tanstack/react-table"
import { Badge } from "@/components/ui/badge"
import { Button } from "@/components/ui/button"
+import { Checkbox } from "@/components/ui/checkbox"
import {
Eye, Calendar, Users, CheckCircle, XCircle, Clock, AlertTriangle
} from "lucide-react"
@@ -91,6 +92,25 @@ const formatCurrency = (amount: string | number | null, currency = 'KRW') => {
export function getBiddingsReceiveColumns({ setRowAction }: GetColumnsProps): ColumnDef<BiddingReceiveItem>[] {
return [
+ // ░░░ 선택 ░░░
+ {
+ id: "select",
+ header: "",
+ cell: ({ row }) => (
+ <Checkbox
+ checked={row.getIsSelected()}
+ onCheckedChange={(value) => {
+ // single select 모드에서는 다른 행들의 선택을 해제
+ row.toggleSelected(!!value)
+ }}
+ aria-label="행 선택"
+ />
+ ),
+ size: 50,
+ enableSorting: false,
+ enableHiding: false,
+ },
+
// ░░░ 입찰번호 ░░░
{
accessorKey: "biddingNumber",
@@ -110,7 +130,7 @@ export function getBiddingsReceiveColumns({ setRowAction }: GetColumnsProps): Co
header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="입찰명" />,
cell: ({ row }) => (
<div className="truncate max-w-[200px]" title={row.original.title}>
- <Button
+ {/* <Button
variant="link"
className="p-0 h-auto text-left justify-start font-bold underline"
onClick={() => setRowAction({ row, type: "view" })}
@@ -118,7 +138,8 @@ export function getBiddingsReceiveColumns({ setRowAction }: GetColumnsProps): Co
<div className="whitespace-pre-line">
{row.original.title}
</div>
- </Button>
+ </Button> */}
+ {row.original.title}
</div>
),
size: 200,
@@ -175,8 +196,8 @@ export function getBiddingsReceiveColumns({ setRowAction }: GetColumnsProps): Co
if (!startDate || !endDate) return <span className="text-muted-foreground">-</span>
const now = new Date()
- const isActive = now >= new Date(startDate) && now <= new Date(endDate)
- const isPast = now > new Date(endDate)
+ const isActive = now >= startDate && now <= endDate
+ const isPast = now > endDate
return (
<div className="text-xs">
@@ -315,7 +336,7 @@ export function getBiddingsReceiveColumns({ setRowAction }: GetColumnsProps): Co
accessorKey: "createdAt",
header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="등록일시" />,
cell: ({ row }) => (
- <span className="text-sm">{formatDate(row.original.createdAt, "KR")}</span>
+ <span className="text-sm">{row.original.createdAt ? formatDate(row.original.createdAt, "KR") : '-'}</span>
),
size: 100,
meta: { excelHeader: "등록일시" },
@@ -324,37 +345,28 @@ export function getBiddingsReceiveColumns({ setRowAction }: GetColumnsProps): Co
// ═══════════════════════════════════════════════════════════════
// 액션
// ═══════════════════════════════════════════════════════════════
- {
- id: "actions",
- header: "액션",
- cell: ({ row }) => (
- <DropdownMenu>
- <DropdownMenuTrigger asChild>
- <Button variant="ghost" className="h-8 w-8 p-0">
- <span className="sr-only">메뉴 열기</span>
- <AlertTriangle className="h-4 w-4" />
- </Button>
- </DropdownMenuTrigger>
- <DropdownMenuContent align="end">
- <DropdownMenuItem onClick={() => setRowAction({ row, type: "view" })}>
- <Eye className="mr-2 h-4 w-4" />
- 상세보기
- </DropdownMenuItem>
- {row.original.status === 'bidding_closed' && (
- <>
- <DropdownMenuSeparator />
- <DropdownMenuItem onClick={() => setRowAction({ row, type: "open_bidding" })}>
- <Calendar className="mr-2 h-4 w-4" />
- 개찰하기
- </DropdownMenuItem>
- </>
- )}
- </DropdownMenuContent>
- </DropdownMenu>
- ),
- size: 50,
- enableSorting: false,
- enableHiding: false,
- },
+ // {
+ // id: "actions",
+ // header: "액션",
+ // cell: ({ row }) => (
+ // <DropdownMenu>
+ // <DropdownMenuTrigger asChild>
+ // <Button variant="ghost" className="h-8 w-8 p-0">
+ // <span className="sr-only">메뉴 열기</span>
+ // <AlertTriangle className="h-4 w-4" />
+ // </Button>
+ // </DropdownMenuTrigger>
+ // <DropdownMenuContent align="end">
+ // <DropdownMenuItem onClick={() => setRowAction({ row, type: "view" })}>
+ // <Eye className="mr-2 h-4 w-4" />
+ // 상세보기
+ // </DropdownMenuItem>
+ // </DropdownMenuContent>
+ // </DropdownMenu>
+ // ),
+ // size: 50,
+ // enableSorting: false,
+ // enableHiding: false,
+ // },
]
}
diff --git a/lib/bidding/receive/biddings-receive-table.tsx b/lib/bidding/receive/biddings-receive-table.tsx
index 88fade40..873f3fa4 100644
--- a/lib/bidding/receive/biddings-receive-table.tsx
+++ b/lib/bidding/receive/biddings-receive-table.tsx
@@ -8,6 +8,9 @@ import type {
DataTableFilterField,
DataTableRowAction,
} from "@/types/table"
+import { Button } from "@/components/ui/button"
+import { Loader2 } from "lucide-react"
+import { toast } from "sonner"
import { useDataTable } from "@/hooks/use-data-table"
import { DataTable } from "@/components/data-table/data-table"
@@ -18,7 +21,8 @@ import {
biddingStatusLabels,
contractTypeLabels,
} from "@/db/schema"
-import { SpecificationMeetingDialog, PrDocumentsDialog } from "../list/bidding-detail-dialogs"
+// import { SpecificationMeetingDialog, PrDocumentsDialog } from "../list/bidding-detail-dialogs"
+import { openBiddingAction, earlyOpenBiddingAction } from "@/lib/bidding/actions"
type BiddingReceiveItem = {
id: number
@@ -62,11 +66,13 @@ export function BiddingsReceiveTable({ promises }: BiddingsReceiveTableProps) {
const { data, pageCount } = biddingsResult
const [isCompact, setIsCompact] = React.useState<boolean>(false)
- const [specMeetingDialogOpen, setSpecMeetingDialogOpen] = React.useState(false)
- const [prDocumentsDialogOpen, setPrDocumentsDialogOpen] = React.useState(false)
- const [selectedBidding, setSelectedBidding] = React.useState<BiddingReceiveItem | null>(null)
+ // const [specMeetingDialogOpen, setSpecMeetingDialogOpen] = React.useState(false)
+ // const [prDocumentsDialogOpen, setPrDocumentsDialogOpen] = React.useState(false)
+ // const [selectedBidding, setSelectedBidding] = React.useState<BiddingReceiveItem | null>(null)
const [rowAction, setRowAction] = React.useState<DataTableRowAction<BiddingReceiveItem> | null>(null)
+ const [isOpeningBidding, setIsOpeningBidding] = React.useState(false)
+ const [isEarlyOpeningBidding, setIsEarlyOpeningBidding] = React.useState(false)
const router = useRouter()
const { data: session } = useSession()
@@ -86,10 +92,6 @@ export function BiddingsReceiveTable({ promises }: BiddingsReceiveTableProps) {
// 상세 페이지로 이동
router.push(`/evcp/bid/${rowAction.row.original.id}`)
break
- case "open_bidding":
- // 개찰하기 (추후 구현)
- console.log('개찰하기:', rowAction.row.original)
- break
default:
break
}
@@ -100,19 +102,16 @@ export function BiddingsReceiveTable({ promises }: BiddingsReceiveTableProps) {
{
id: "biddingNumber",
label: "입찰번호",
- type: "text",
placeholder: "입찰번호를 입력하세요",
},
{
id: "prNumber",
label: "P/R번호",
- type: "text",
placeholder: "P/R번호를 입력하세요",
},
{
id: "title",
label: "입찰명",
- type: "text",
placeholder: "입찰명을 입력하세요",
},
]
@@ -151,6 +150,7 @@ export function BiddingsReceiveTable({ promises }: BiddingsReceiveTableProps) {
filterFields,
enablePinning: true,
enableAdvancedFilter: true,
+ enableRowSelection: true,
initialState: {
sorting: [{ id: "createdAt", desc: true }],
columnPinning: { right: ["actions"] },
@@ -164,17 +164,96 @@ export function BiddingsReceiveTable({ promises }: BiddingsReceiveTableProps) {
setIsCompact(compact)
}, [])
- const handleSpecMeetingDialogClose = React.useCallback(() => {
- setSpecMeetingDialogOpen(false)
- setRowAction(null)
- setSelectedBidding(null)
- }, [])
+ // const handleSpecMeetingDialogClose = React.useCallback(() => {
+ // setSpecMeetingDialogOpen(false)
+ // setRowAction(null)
+ // setSelectedBidding(null)
+ // }, [])
- const handlePrDocumentsDialogClose = React.useCallback(() => {
- setPrDocumentsDialogOpen(false)
- setRowAction(null)
- setSelectedBidding(null)
- }, [])
+ // const handlePrDocumentsDialogClose = React.useCallback(() => {
+ // setPrDocumentsDialogOpen(false)
+ // setRowAction(null)
+ // setSelectedBidding(null)
+ // }, [])
+
+ // 선택된 행 가져오기
+ const selectedRows = table.getFilteredSelectedRowModel().rows
+ const selectedBiddingForAction = selectedRows.length > 0 ? selectedRows[0].original : null
+
+ // 조기개찰 가능 여부 확인
+ const canEarlyOpen = React.useMemo(() => {
+ if (!selectedBiddingForAction) return false
+
+ const now = new Date()
+ const submissionEndDate = selectedBiddingForAction.submissionEndDate
+
+ // 참여협력사가 1명 이상이어야 함
+ if (selectedBiddingForAction.participantParticipated < 1) return false
+
+ // 입찰서 제출기간 내여야 함
+ if (!submissionEndDate || now > submissionEndDate) return false
+
+ // 미제출 협력사가 0이어야 함
+ if (selectedBiddingForAction.participantPending > 0) return false
+
+ // 참여협력사 + 포기협력사 = 참여예정협력사 여야 함
+ const participatedOrDeclined = selectedBiddingForAction.participantParticipated + selectedBiddingForAction.participantDeclined
+ return participatedOrDeclined === selectedBiddingForAction.participantExpected
+ }, [selectedBiddingForAction])
+
+ // 개찰 가능 여부 확인
+ const canOpen = React.useMemo(() => {
+ if (!selectedBiddingForAction) return false
+
+ // 참여협력사가 1명 이상이어야 함
+ if (selectedBiddingForAction.participantParticipated < 1) return false
+
+ const now = new Date()
+ const submissionEndDate = selectedBiddingForAction.submissionEndDate
+
+ // 입찰서 제출기간이 종료되어야 함
+ return submissionEndDate && now > submissionEndDate
+ }, [selectedBiddingForAction])
+
+ const handleEarlyOpenBidding = React.useCallback(async () => {
+ if (!selectedBiddingForAction) return
+
+ setIsEarlyOpeningBidding(true)
+ try {
+ const result = await earlyOpenBiddingAction(selectedBiddingForAction.id)
+ if (result.success) {
+ toast.success("조기개찰이 완료되었습니다.")
+ // 데이터 리프레시
+ window.location.reload()
+ } else {
+ toast.error(result.message || "조기개찰에 실패했습니다.")
+ }
+ } catch (error) {
+ toast.error("조기개찰 중 오류가 발생했습니다.")
+ } finally {
+ setIsEarlyOpeningBidding(false)
+ }
+ }, [selectedBiddingForAction])
+
+ const handleOpenBidding = React.useCallback(async () => {
+ if (!selectedBiddingForAction) return
+
+ setIsOpeningBidding(true)
+ try {
+ const result = await openBiddingAction(selectedBiddingForAction.id)
+ if (result.success) {
+ toast.success("개찰이 완료되었습니다.")
+ // 데이터 리프레시
+ window.location.reload()
+ } else {
+ toast.error(result.message || "개찰에 실패했습니다.")
+ }
+ } catch (error) {
+ toast.error("개찰 중 오류가 발생했습니다.")
+ } finally {
+ setIsOpeningBidding(false)
+ }
+ }, [selectedBiddingForAction])
return (
<>
@@ -190,22 +269,42 @@ export function BiddingsReceiveTable({ promises }: BiddingsReceiveTableProps) {
compactStorageKey="biddingsReceiveTableCompact"
onCompactChange={handleCompactChange}
>
+ <div className="flex items-center gap-2">
+ <Button
+ variant="outline"
+ size="sm"
+ onClick={handleEarlyOpenBidding}
+ disabled={!selectedBiddingForAction || !canEarlyOpen || isEarlyOpeningBidding}
+ >
+ {isEarlyOpeningBidding && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
+ 조기개찰
+ </Button>
+ <Button
+ variant="outline"
+ size="sm"
+ onClick={handleOpenBidding}
+ disabled={!selectedBiddingForAction || !canOpen || isOpeningBidding}
+ >
+ {isOpeningBidding && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
+ 개찰
+ </Button>
+ </div>
</DataTableAdvancedToolbar>
</DataTable>
{/* 사양설명회 다이얼로그 */}
- <SpecificationMeetingDialog
+ {/* <SpecificationMeetingDialog
open={specMeetingDialogOpen}
onOpenChange={handleSpecMeetingDialogClose}
bidding={selectedBidding}
- />
+ /> */}
{/* PR 문서 다이얼로그 */}
- <PrDocumentsDialog
+ {/* <PrDocumentsDialog
open={prDocumentsDialogOpen}
onOpenChange={handlePrDocumentsDialogClose}
bidding={selectedBidding}
- />
+ /> */}
</>
)
}
diff --git a/lib/bidding/selection/actions.ts b/lib/bidding/selection/actions.ts
new file mode 100644
index 00000000..e17e9292
--- /dev/null
+++ b/lib/bidding/selection/actions.ts
@@ -0,0 +1,219 @@
+"use server"
+
+import db from "@/db/db"
+import { eq, and, sql, isNull } from "drizzle-orm"
+import { getServerSession } from "next-auth/next"
+import { authOptions } from "@/app/api/auth/[...nextauth]/route"
+// @ts-ignore - Next.js cache import issue in server actions
+const { revalidatePath } = require("next/cache")
+import {
+ biddings,
+ biddingCompanies,
+ prItemsForBidding,
+ companyPrItemBids,
+ vendors,
+ generalContracts,
+ generalContractItems,
+ vendorSelectionResults,
+ biddingDocuments
+} from "@/db/schema"
+
+interface SaveSelectionResultData {
+ biddingId: number
+ summary: string
+ attachments?: File[]
+}
+
+export async function saveSelectionResult(data: SaveSelectionResultData) {
+ try {
+ const session = await getServerSession(authOptions)
+ if (!session?.user?.id) {
+ return {
+ success: false,
+ error: '인증되지 않은 사용자입니다.'
+ }
+ }
+
+ // 기존 선정결과 확인 (selectedCompanyId가 null인 레코드)
+ // 타입 에러를 무시하고 전체 조회 후 필터링
+ const allResults = await db
+ .select()
+ .from(vendorSelectionResults)
+ .where(eq(vendorSelectionResults.biddingId, data.biddingId))
+
+ // @ts-ignore
+ const existingResult = allResults.filter((result: any) => result.selectedCompanyId === null).slice(0, 1)
+
+ const resultData = {
+ biddingId: data.biddingId,
+ selectedCompanyId: null, // 전체 선정결과
+ selectionReason: '전체 선정결과',
+ evaluationSummary: data.summary,
+ hasResultDocuments: data.attachments && data.attachments.length > 0,
+ selectedBy: session.user.id
+ }
+
+ let resultId: number
+
+ if (existingResult.length > 0) {
+ // 업데이트
+ await db
+ .update(vendorSelectionResults)
+ .set({
+ ...resultData,
+ updatedAt: new Date()
+ })
+ .where(eq(vendorSelectionResults.id, existingResult[0].id))
+ resultId = existingResult[0].id
+ } else {
+ // 새로 생성
+ const insertResult = await db.insert(vendorSelectionResults).values(resultData).returning({ id: vendorSelectionResults.id })
+ resultId = insertResult[0].id
+ }
+
+ // 첨부파일 처리
+ if (data.attachments && data.attachments.length > 0) {
+ // 기존 첨부파일 삭제 (documentType이 'selection_result'인 것들)
+ await db
+ .delete(biddingDocuments)
+ .where(and(
+ eq(biddingDocuments.biddingId, data.biddingId),
+ eq(biddingDocuments.documentType, 'selection_result')
+ ))
+
+ // 새 첨부파일 저장
+ const documentInserts = data.attachments.map(file => ({
+ biddingId: data.biddingId,
+ companyId: null,
+ documentType: 'selection_result' as const,
+ fileName: file.name,
+ originalFileName: file.name,
+ fileSize: file.size,
+ mimeType: file.type,
+ filePath: `/uploads/bidding/${data.biddingId}/selection/${file.name}`, // 실제 파일 저장 로직 필요
+ uploadedBy: session.user.id
+ }))
+
+ await db.insert(biddingDocuments).values(documentInserts)
+ }
+
+ revalidatePath(`/evcp/bid-selection/${data.biddingId}/detail`)
+
+ return {
+ success: true,
+ message: '선정결과가 성공적으로 저장되었습니다.'
+ }
+ } catch (error) {
+ console.error('Failed to save selection result:', error)
+ return {
+ success: false,
+ error: '선정결과 저장 중 오류가 발생했습니다.'
+ }
+ }
+}
+
+// 견적 히스토리 조회
+export async function getQuotationHistory(biddingId: number, vendorId: number) {
+ try {
+ // biddingCompanies에서 해당 벤더의 스냅샷 데이터 조회
+ const companyData = await db
+ .select({
+ quotationSnapshots: biddingCompanies.quotationSnapshots
+ })
+ .from(biddingCompanies)
+ .where(and(
+ eq(biddingCompanies.biddingId, biddingId),
+ eq(biddingCompanies.companyId, vendorId)
+ ))
+ .limit(1)
+
+ if (!companyData.length || !companyData[0].quotationSnapshots) {
+ return {
+ success: true,
+ data: {
+ history: []
+ }
+ }
+ }
+
+ const snapshots = companyData[0].quotationSnapshots as any[]
+
+ // PR 항목 정보 조회 (스냅샷의 prItemId로 매핑하기 위해)
+ const prItemIds = snapshots.flatMap(snapshot =>
+ snapshot.items?.map((item: any) => item.prItemId) || []
+ ).filter((id: number, index: number, arr: number[]) => arr.indexOf(id) === index)
+
+ const prItems = prItemIds.length > 0 ? await db
+ .select({
+ id: prItemsForBidding.id,
+ itemCode: prItemsForBidding.itemCode,
+ itemName: prItemsForBidding.itemName,
+ specification: prItemsForBidding.specification,
+ quantity: prItemsForBidding.quantity,
+ unit: prItemsForBidding.unit,
+ deliveryDate: prItemsForBidding.deliveryDate
+ })
+ .from(prItemsForBidding)
+ .where(sql`${prItemsForBidding.id} IN ${prItemIds}`) : []
+
+ // PR 항목을 Map으로 변환하여 빠른 조회를 위해
+ const prItemMap = new Map(prItems.map(item => [item.id, item]))
+
+ // bidding 정보 조회 (targetPrice, currency)
+ const biddingInfo = await db
+ .select({
+ targetPrice: biddings.targetPrice,
+ currency: biddings.currency
+ })
+ .from(biddings)
+ .where(eq(biddings.id, biddingId))
+ .limit(1)
+
+ const targetPrice = biddingInfo[0]?.targetPrice ? parseFloat(biddingInfo[0].targetPrice.toString()) : null
+ const currency = biddingInfo[0]?.currency || 'KRW'
+
+ // 스냅샷 데이터를 변환
+ const history = snapshots.map((snapshot: any) => {
+ const vsTargetPrice = targetPrice && targetPrice > 0
+ ? ((snapshot.totalAmount - targetPrice) / targetPrice) * 100
+ : 0
+
+ const items = snapshot.items?.map((item: any) => {
+ const prItem = prItemMap.get(item.prItemId)
+ return {
+ itemCode: prItem?.itemCode || `ITEM${item.prItemId}`,
+ itemName: prItem?.itemName || '품목 정보 없음',
+ specification: prItem?.specification || item.technicalSpecification || '-',
+ quantity: prItem?.quantity || 0,
+ unit: prItem?.unit || 'EA',
+ unitPrice: item.bidUnitPrice,
+ totalPrice: item.bidAmount,
+ deliveryDate: item.proposedDeliveryDate ? new Date(item.proposedDeliveryDate) : prItem?.deliveryDate ? new Date(prItem.deliveryDate) : new Date()
+ }
+ }) || []
+
+ return {
+ id: snapshot.id,
+ round: snapshot.round,
+ submittedAt: new Date(snapshot.submittedAt),
+ totalAmount: snapshot.totalAmount,
+ currency: snapshot.currency || currency,
+ vsTargetPrice: parseFloat(vsTargetPrice.toFixed(2)),
+ items
+ }
+ })
+
+ return {
+ success: true,
+ data: {
+ history
+ }
+ }
+ } catch (error) {
+ console.error('Failed to get quotation history:', error)
+ return {
+ success: false,
+ error: '견적 히스토리 조회 중 오류가 발생했습니다.'
+ }
+ }
+}
diff --git a/lib/bidding/selection/bidding-info-card.tsx b/lib/bidding/selection/bidding-info-card.tsx
new file mode 100644
index 00000000..f6f0bc69
--- /dev/null
+++ b/lib/bidding/selection/bidding-info-card.tsx
@@ -0,0 +1,96 @@
+import * as React from 'react'
+import { Bidding } from '@/db/schema'
+import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
+import { Badge } from '@/components/ui/badge'
+// import { formatDate } from '@/lib/utils'
+import { biddingStatusLabels, contractTypeLabels } from '@/db/schema'
+
+interface BiddingInfoCardProps {
+ bidding: Bidding
+}
+
+export function BiddingInfoCard({ bidding }: BiddingInfoCardProps) {
+ return (
+ <Card>
+ <CardHeader>
+ <CardTitle>입찰정보</CardTitle>
+ </CardHeader>
+ <CardContent>
+ <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
+ {/* 입찰명 */}
+ <div className="space-y-2">
+ <label className="text-sm font-medium text-muted-foreground">
+ 입찰명
+ </label>
+ <div className="text-sm font-medium">
+ {bidding.title || '-'}
+ </div>
+ </div>
+
+ {/* 입찰번호 */}
+ <div className="space-y-2">
+ <label className="text-sm font-medium text-muted-foreground">
+ 입찰번호
+ </label>
+ <div className="text-sm font-medium">
+ {bidding.biddingNumber || '-'}
+ </div>
+ </div>
+
+ {/* 내정가 */}
+ <div className="space-y-2">
+ <label className="text-sm font-medium text-muted-foreground">
+ 내정가
+ </label>
+ <div className="text-sm font-medium">
+ {bidding.targetPrice
+ ? `${Number(bidding.targetPrice).toLocaleString()} ${bidding.currency || 'KRW'}`
+ : '-'
+ }
+ </div>
+ </div>
+
+ {/* 입찰유형 */}
+ <div className="space-y-2">
+ <label className="text-sm font-medium text-muted-foreground">
+ 입찰유형
+ </label>
+ <div className="text-sm font-medium">
+ {bidding.isPublic ? '공개입찰' : '비공개입찰'}
+ </div>
+ </div>
+
+ {/* 진행상태 */}
+ <div className="space-y-2">
+ <label className="text-sm font-medium text-muted-foreground">
+ 진행상태
+ </label>
+ <Badge variant="secondary">
+ {biddingStatusLabels[bidding.status as keyof typeof biddingStatusLabels] || bidding.status}
+ </Badge>
+ </div>
+
+ {/* 입찰담당자 */}
+ <div className="space-y-2">
+ <label className="text-sm font-medium text-muted-foreground">
+ 입찰담당자
+ </label>
+ <div className="text-sm font-medium">
+ {bidding.bidPicName || '-'}
+ </div>
+ </div>
+
+ {/* 계약구분 */}
+ <div className="space-y-2">
+ <label className="text-sm font-medium text-muted-foreground">
+ 계약구분
+ </label>
+ <div className="text-sm font-medium">
+ {contractTypeLabels[bidding.contractType as keyof typeof contractTypeLabels] || bidding.contractType}
+ </div>
+ </div>
+ </div>
+ </CardContent>
+ </Card>
+ )
+}
diff --git a/lib/bidding/selection/bidding-selection-detail-content.tsx b/lib/bidding/selection/bidding-selection-detail-content.tsx
new file mode 100644
index 00000000..45d5d402
--- /dev/null
+++ b/lib/bidding/selection/bidding-selection-detail-content.tsx
@@ -0,0 +1,41 @@
+'use client'
+
+import * as React from 'react'
+import { Bidding } from '@/db/schema'
+import { BiddingInfoCard } from './bidding-info-card'
+import { SelectionResultForm } from './selection-result-form'
+import { VendorSelectionTable } from './vendor-selection-table'
+
+interface BiddingSelectionDetailContentProps {
+ biddingId: number
+ bidding: Bidding
+}
+
+export function BiddingSelectionDetailContent({
+ biddingId,
+ bidding
+}: BiddingSelectionDetailContentProps) {
+ const [refreshKey, setRefreshKey] = React.useState(0)
+
+ const handleRefresh = React.useCallback(() => {
+ setRefreshKey(prev => prev + 1)
+ }, [])
+
+ return (
+ <div className="space-y-6">
+ {/* 입찰정보 카드 */}
+ <BiddingInfoCard bidding={bidding} />
+
+ {/* 선정결과 폼 */}
+ <SelectionResultForm biddingId={biddingId} onSuccess={handleRefresh} />
+
+ {/* 업체선정 테이블 */}
+ <VendorSelectionTable
+ key={refreshKey}
+ biddingId={biddingId}
+ bidding={bidding}
+ onRefresh={handleRefresh}
+ />
+ </div>
+ )
+}
diff --git a/lib/bidding/selection/biddings-selection-columns.tsx b/lib/bidding/selection/biddings-selection-columns.tsx
index bbcd2d77..0d1a8c9d 100644
--- a/lib/bidding/selection/biddings-selection-columns.tsx
+++ b/lib/bidding/selection/biddings-selection-columns.tsx
@@ -256,10 +256,6 @@ export function getBiddingsSelectionColumns({ setRowAction }: GetColumnsProps):
<Eye className="mr-2 h-4 w-4" />
상세보기
</DropdownMenuItem>
- <DropdownMenuItem onClick={() => setRowAction({ row, type: "detail" })}>
- <FileText className="mr-2 h-4 w-4" />
- 상세분석
- </DropdownMenuItem>
{row.original.status === 'bidding_opened' && (
<>
<DropdownMenuSeparator />
diff --git a/lib/bidding/selection/biddings-selection-table.tsx b/lib/bidding/selection/biddings-selection-table.tsx
index 912a7154..9545fe09 100644
--- a/lib/bidding/selection/biddings-selection-table.tsx
+++ b/lib/bidding/selection/biddings-selection-table.tsx
@@ -83,10 +83,6 @@ export function BiddingsSelectionTable({ promises }: BiddingsSelectionTableProps
switch (rowAction.type) {
case "view":
// 상세 페이지로 이동
- router.push(`/evcp/bid/${rowAction.row.original.id}`)
- break
- case "detail":
- // 상세분석 페이지로 이동 (추후 구현)
router.push(`/evcp/bid-selection/${rowAction.row.original.id}/detail`)
break
case "close_bidding":
diff --git a/lib/bidding/selection/selection-result-form.tsx b/lib/bidding/selection/selection-result-form.tsx
new file mode 100644
index 00000000..7f1229a2
--- /dev/null
+++ b/lib/bidding/selection/selection-result-form.tsx
@@ -0,0 +1,143 @@
+'use client'
+
+import * as React from 'react'
+import { useForm } from 'react-hook-form'
+import { zodResolver } from '@hookform/resolvers/zod'
+import * as z from 'zod'
+import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
+import { Button } from '@/components/ui/button'
+import { Textarea } from '@/components/ui/textarea'
+import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from '@/components/ui/form'
+import { FileUpload } from '@/components/ui/file-upload'
+import { useToast } from '@/hooks/use-toast'
+import { saveSelectionResult } from './actions'
+import { Loader2, Save } from 'lucide-react'
+
+const selectionResultSchema = z.object({
+ summary: z.string().min(1, '결과요약을 입력해주세요'),
+ attachments: z.array(z.any()).optional(),
+})
+
+type SelectionResultFormData = z.infer<typeof selectionResultSchema>
+
+interface SelectionResultFormProps {
+ biddingId: number
+ onSuccess: () => void
+}
+
+export function SelectionResultForm({ biddingId, onSuccess }: SelectionResultFormProps) {
+ const { toast } = useToast()
+ const [isSubmitting, setIsSubmitting] = React.useState(false)
+
+ const form = useForm<SelectionResultFormData>({
+ resolver: zodResolver(selectionResultSchema),
+ defaultValues: {
+ summary: '',
+ attachments: [],
+ },
+ })
+
+ const onSubmit = async (data: SelectionResultFormData) => {
+ setIsSubmitting(true)
+ try {
+ const result = await saveSelectionResult({
+ biddingId,
+ summary: data.summary,
+ attachments: data.attachments
+ })
+
+ if (result.success) {
+ toast({
+ title: '저장 완료',
+ description: result.message,
+ })
+ onSuccess()
+ } else {
+ toast({
+ title: '저장 실패',
+ description: result.error,
+ variant: 'destructive',
+ })
+ }
+ } catch (error) {
+ console.error('Failed to save selection result:', error)
+ toast({
+ title: '저장 실패',
+ description: '선정결과 저장 중 오류가 발생했습니다.',
+ variant: 'destructive',
+ })
+ } finally {
+ setIsSubmitting(false)
+ }
+ }
+
+ return (
+ <Card>
+ <CardHeader>
+ <CardTitle>선정결과</CardTitle>
+ </CardHeader>
+ <CardContent>
+ <Form {...form}>
+ <form onSubmit={form.handleSubmit(onSubmit)} className="space-y-6">
+ {/* 결과요약 */}
+ <FormField
+ control={form.control}
+ name="summary"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>결과요약</FormLabel>
+ <FormControl>
+ <Textarea
+ placeholder="선정결과에 대한 요약을 입력해주세요..."
+ className="min-h-[120px]"
+ {...field}
+ />
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+
+ {/* 첨부파일 */}
+ <FormField
+ control={form.control}
+ name="attachments"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>첨부파일</FormLabel>
+ <FormControl>
+ <FileUpload
+ value={field.value || []}
+ onChange={field.onChange}
+ accept={{
+ 'application/pdf': ['.pdf'],
+ 'application/msword': ['.doc'],
+ 'application/vnd.openxmlformats-officedocument.wordprocessingml.document': ['.docx'],
+ 'application/vnd.ms-excel': ['.xls'],
+ 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet': ['.xlsx'],
+ 'image/*': ['.png', '.jpg', '.jpeg', '.gif'],
+ }}
+ maxSize={10 * 1024 * 1024} // 10MB
+ maxFiles={5}
+ placeholder="선정결과 관련 문서를 업로드해주세요"
+ />
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+
+ {/* 저장 버튼 */}
+ <div className="flex justify-end">
+ <Button type="submit" disabled={isSubmitting}>
+ {isSubmitting && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
+ <Save className="mr-2 h-4 w-4" />
+ 저장
+ </Button>
+ </div>
+ </form>
+ </Form>
+ </CardContent>
+ </Card>
+ )
+}
diff --git a/lib/bidding/selection/vendor-selection-table.tsx b/lib/bidding/selection/vendor-selection-table.tsx
new file mode 100644
index 00000000..8570b5b6
--- /dev/null
+++ b/lib/bidding/selection/vendor-selection-table.tsx
@@ -0,0 +1,66 @@
+'use client'
+
+import * as React from 'react'
+import { Bidding } from '@/db/schema'
+import { BiddingDetailVendorTableContent } from '../detail/table/bidding-detail-vendor-table'
+import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
+import { getBiddingDetailData } from '../detail/service'
+
+interface VendorSelectionTableProps {
+ biddingId: number
+ bidding: Bidding
+ onRefresh: () => void
+}
+
+export function VendorSelectionTable({ biddingId, bidding, onRefresh }: VendorSelectionTableProps) {
+ const [vendors, setVendors] = React.useState<any[]>([])
+ const [loading, setLoading] = React.useState(true)
+
+ React.useEffect(() => {
+ const loadData = async () => {
+ try {
+ setLoading(true)
+ const data = await getBiddingDetailData(biddingId)
+ setVendors(data.quotationVendors)
+ } catch (error) {
+ console.error('Failed to load vendors:', error)
+ } finally {
+ setLoading(false)
+ }
+ }
+
+ loadData()
+ }, [biddingId])
+
+ if (loading) {
+ return (
+ <Card>
+ <CardHeader>
+ <CardTitle>업체선정</CardTitle>
+ </CardHeader>
+ <CardContent>
+ <div className="flex items-center justify-center py-8">
+ <div className="text-sm text-muted-foreground">로딩 중...</div>
+ </div>
+ </CardContent>
+ </Card>
+ )
+ }
+
+ return (
+ <Card>
+ <CardHeader>
+ <CardTitle>업체선정</CardTitle>
+ </CardHeader>
+ <CardContent>
+ <BiddingDetailVendorTableContent
+ biddingId={biddingId}
+ bidding={bidding}
+ vendors={vendors}
+ onRefresh={onRefresh}
+ onOpenSelectionReasonDialog={() => {}}
+ />
+ </CardContent>
+ </Card>
+ )
+}
diff --git a/lib/bidding/service.ts b/lib/bidding/service.ts
index 80e4850f..a7cd8286 100644
--- a/lib/bidding/service.ts
+++ b/lib/bidding/service.ts
@@ -3076,7 +3076,14 @@ export async function searchVendorsForBidding(searchTerm: string = "", biddingId
}
// 차수증가 또는 재입찰 함수
-export async function increaseRoundOrRebid(biddingId: number, userId: string, type: 'round_increase' | 'rebidding') {
+export async function increaseRoundOrRebid(biddingId: number, userId: string | undefined, type: 'round_increase' | 'rebidding') {
+ if (!userId) {
+ return {
+ success: false,
+ error: '사용자 정보가 필요합니다.',
+ }
+ }
+
try {
const userName = await getUserNameById(userId)
@@ -3429,7 +3436,8 @@ export async function getBiddingsForReceive(input: GetBiddingsSchema) {
or(
eq(biddings.status, 'received_quotation'),
eq(biddings.status, 'bidding_opened'),
- eq(biddings.status, 'bidding_closed')
+ eq(biddings.status, 'bidding_closed'),
+ eq(biddings.status, 'evaluation_of_bidding'),
)!
)
@@ -3577,9 +3585,9 @@ export async function getBiddingsForReceive(input: GetBiddingsSchema) {
), 0)
`.as('participant_pending'),
- // 개찰 정보 (bidding_opened 상태에서만 의미 있음)
- openedAt: biddings.updatedAt, // 개찰일은 업데이트 일시로 대체
- openedBy: biddings.updatedBy, // 개찰자는 업데이트자로 대체
+ // 개찰 정보
+ openedAt: biddings.openedAt,
+ openedBy: biddings.openedBy,
})
.from(biddings)
.where(finalWhere)
@@ -3756,8 +3764,13 @@ export async function getBiddingsForFailure(input: GetBiddingsSchema) {
// 기본 필터 조건들 (유찰된 입찰만)
const basicConditions: SQL<unknown>[] = []
- // 유찰된 상태만 필터링
- basicConditions.push(eq(biddings.status, 'bidding_disposal'))
+ // 유찰된 상태만 필터링, 폐찰된 상태도 포함
+ basicConditions.push(
+ or(
+ eq(biddings.status, 'bidding_disposal'),
+ eq(biddings.status, 'bid_closure')
+ )!
+ )
if (input.biddingNumber) {
basicConditions.push(ilike(biddings.biddingNumber, `%${input.biddingNumber}%`))
@@ -3848,8 +3861,8 @@ export async function getBiddingsForFailure(input: GetBiddingsSchema) {
orderByColumns.push(desc(biddings.updatedAt)) // 유찰된 최신순
}
- // bid-failure 페이지용 데이터 조회
- const data = await db
+ // bid-failure 페이지용 데이터 조회 (폐찰 문서 정보 포함)
+ const rawData = await db
.select({
// 기본 입찰 정보
id: biddings.id,
@@ -3878,6 +3891,15 @@ export async function getBiddingsForFailure(input: GetBiddingsSchema) {
disposalUpdatedAt: biddings.updatedAt, // 폐찰수정일
disposalUpdatedBy: biddings.updatedBy, // 폐찰수정자
+ // 폐찰 정보
+ closureReason: biddings.description, // 폐찰사유
+
+ // 폐찰 문서 정보
+ documentId: biddingDocuments.id,
+ documentFileName: biddingDocuments.fileName,
+ documentOriginalFileName: biddingDocuments.originalFileName,
+ documentFilePath: biddingDocuments.filePath,
+
// 기타 정보
createdBy: biddings.createdBy,
createdAt: biddings.createdAt,
@@ -3885,11 +3907,67 @@ export async function getBiddingsForFailure(input: GetBiddingsSchema) {
updatedBy: biddings.updatedBy,
})
.from(biddings)
+ .leftJoin(biddingDocuments, and(
+ eq(biddingDocuments.biddingId, biddings.id),
+ eq(biddingDocuments.documentType, 'evaluation_doc'), // 폐찰 문서
+ eq(biddingDocuments.isPublic, false) // 폐찰 문서는 비공개
+ ))
.where(finalWhere)
.orderBy(...orderByColumns)
.limit(input.perPage)
.offset(offset)
+ // 데이터를 그룹화하여 폐찰 문서들을 배열로 묶기
+ const groupedData = rawData.reduce((acc, item) => {
+ const existing = acc.find(b => b.id === item.id)
+ if (existing) {
+ // 이미 존재하는 입찰이면 문서 추가
+ if (item.documentId) {
+ existing.closureDocuments.push({
+ id: item.documentId,
+ fileName: item.documentFileName!,
+ originalFileName: item.documentOriginalFileName!,
+ filePath: item.documentFilePath!
+ })
+ }
+ } else {
+ // 새로운 입찰 추가
+ acc.push({
+ id: item.id,
+ biddingNumber: item.biddingNumber,
+ originalBiddingNumber: item.originalBiddingNumber,
+ title: item.title,
+ status: item.status,
+ contractType: item.contractType,
+ prNumber: item.prNumber,
+ targetPrice: item.targetPrice,
+ currency: item.currency,
+ biddingRegistrationDate: item.biddingRegistrationDate,
+ submissionStartDate: item.submissionStartDate,
+ submissionEndDate: item.submissionEndDate,
+ bidPicName: item.bidPicName,
+ supplyPicName: item.supplyPicName,
+ disposalDate: item.disposalDate,
+ disposalUpdatedAt: item.disposalUpdatedAt,
+ disposalUpdatedBy: item.disposalUpdatedBy,
+ closureReason: item.closureReason,
+ closureDocuments: item.documentId ? [{
+ id: item.documentId,
+ fileName: item.documentFileName!,
+ originalFileName: item.documentOriginalFileName!,
+ filePath: item.documentFilePath!
+ }] : [],
+ createdBy: item.createdBy,
+ createdAt: item.createdAt,
+ updatedAt: item.updatedAt,
+ updatedBy: item.updatedBy,
+ })
+ }
+ return acc
+ }, [] as any[])
+
+ const data = groupedData
+
const pageCount = Math.ceil(total / input.perPage)
return { data, pageCount, total }
diff --git a/lib/bidding/vendor/partners-bidding-detail.tsx b/lib/bidding/vendor/partners-bidding-detail.tsx
index 273c0667..fe254dad 100644
--- a/lib/bidding/vendor/partners-bidding-detail.tsx
+++ b/lib/bidding/vendor/partners-bidding-detail.tsx
@@ -755,7 +755,7 @@ export function PartnersBiddingDetail({ biddingId, companyId }: PartnersBiddingD
biddingDetail.isBiddingParticipated === true ? 'default' : 'destructive'
}>
{biddingDetail.isBiddingParticipated === null ? '참여 결정 대기' :
- biddingDetail.isBiddingParticipated === true ? '응찰' : '미응찰'}
+ biddingDetail.isBiddingParticipated === true ? '응찰' : '응찰포기'}
</Badge>
</div>
@@ -1014,12 +1014,12 @@ export function PartnersBiddingDetail({ biddingId, companyId }: PartnersBiddingD
{/* 참여 상태에 따른 섹션 표시 */}
{biddingDetail.isBiddingParticipated === false ? (
- /* 미응찰 상태 표시 */
+ /* 응찰포기 상태 표시 */
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<XCircle className="w-5 h-5 text-destructive" />
- 입찰 참여 거절
+ 응찰포기
</CardTitle>
</CardHeader>
<CardContent>
diff --git a/lib/general-contracts/detail/general-contract-info-header.tsx b/lib/general-contracts/detail/general-contract-info-header.tsx
index 675918a2..c0a79d09 100644
--- a/lib/general-contracts/detail/general-contract-info-header.tsx
+++ b/lib/general-contracts/detail/general-contract-info-header.tsx
@@ -59,7 +59,8 @@ const typeLabels = {
'AW': '사전작업합의',
'AD': '사전납품합의',
'SG': '임치(물품보관)계약',
- 'SR': '폐기물매각계약'
+ 'SR': '폐기물매각계약',
+ 'SP': 'S-PEpC'
}
export function GeneralContractInfoHeader({ contract }: GeneralContractInfoHeaderProps) {
diff --git a/lib/general-contracts/main/create-general-contract-dialog.tsx b/lib/general-contracts/main/create-general-contract-dialog.tsx
index 04f70834..d0ccfe5b 100644
--- a/lib/general-contracts/main/create-general-contract-dialog.tsx
+++ b/lib/general-contracts/main/create-general-contract-dialog.tsx
@@ -259,7 +259,8 @@ export function CreateGeneralContractDialog() {
'AW': '사전작업합의',
'AD': '사전납품합의',
'SG': '임치(물품보관)계약',
- 'SR': '폐기물매각계약'
+ 'SR': '폐기물매각계약',
+ 'SP': 'S-PEpC'
}
return (
<SelectItem key={type} value={type}>
diff --git a/lib/general-contracts/main/general-contract-update-sheet.tsx b/lib/general-contracts/main/general-contract-update-sheet.tsx
index ac49faca..8df74beb 100644
--- a/lib/general-contracts/main/general-contract-update-sheet.tsx
+++ b/lib/general-contracts/main/general-contract-update-sheet.tsx
@@ -244,7 +244,8 @@ export function GeneralContractUpdateSheet({
'AW': '사전작업합의',
'AD': '사전납품합의',
'SG': '임치(물품보관)계약',
- 'SR': '폐기물매각계약'
+ 'SR': '폐기물매각계약',
+ 'SP': 'S-PEpC'
}
return (
<SelectItem key={type} value={type}>
diff --git a/lib/general-contracts/main/general-contracts-table-columns.tsx b/lib/general-contracts/main/general-contracts-table-columns.tsx
index 932446d2..0b3143fe 100644
--- a/lib/general-contracts/main/general-contracts-table-columns.tsx
+++ b/lib/general-contracts/main/general-contracts-table-columns.tsx
@@ -144,6 +144,8 @@ const getTypeText = (type: string) => {
return '임치(물품보관)계약'
case 'SR':
return '폐기물매각계약'
+ case 'SP':
+ return 'S-PEpC'
default:
return type
}
diff --git a/lib/general-contracts/main/general-contracts-table.tsx b/lib/general-contracts/main/general-contracts-table.tsx
index 813c1798..5428435e 100644
--- a/lib/general-contracts/main/general-contracts-table.tsx
+++ b/lib/general-contracts/main/general-contracts-table.tsx
@@ -56,7 +56,8 @@ const contractTypeLabels = {
'AW': '사전작업합의',
'AD': '사전납품합의',
'SG': '임치(물품보관)계약',
- 'SR': '폐기물매각계약'
+ 'SR': '폐기물매각계약',
+ 'SP': 'S-PEpC'
}
interface GeneralContractsTableProps {
diff --git a/lib/general-contracts/service.ts b/lib/general-contracts/service.ts
index 1b1d5c9f..991616d9 100644
--- a/lib/general-contracts/service.ts
+++ b/lib/general-contracts/service.ts
@@ -1842,7 +1842,8 @@ export async function generateContractNumber(
'AW': 'AW', // 사전작업합의 (Advanced Work)
'AD': 'AD', // 사전납품합의 (Advanced Delivery)
'SG': 'SG', // 임치(물품보관)계약
- 'SR': 'SR' // 폐기물매각계약 (Scrap)
+ 'SR': 'SR', // 폐기물매각계약 (Scrap)
+ 'SP': 'SP' // S-PEpC
}
const typeCode = contractTypeMap[contractType] || 'XX' // 기본값
diff --git a/lib/general-contracts/types.ts b/lib/general-contracts/types.ts
index 33b1189f..6793d76c 100644
--- a/lib/general-contracts/types.ts
+++ b/lib/general-contracts/types.ts
@@ -24,7 +24,8 @@ export const GENERAL_CONTRACT_TYPES = [
'AW', // 사전작업합의 (Advanced Work)
'AD', // 사전납품합의 (Advanced Delivery)
'SG', // 임치(물품보관)계약
- 'SR' // 폐기물매각계약 (Scrap)
+ 'SR', // 폐기물매각계약 (Scrap)
+ 'SP' // S-PEpC
] as const;
export type GeneralContractType = typeof GENERAL_CONTRACT_TYPES[number];
diff --git a/lib/users/auth/passwordUtil.ts b/lib/users/auth/passwordUtil.ts
index 4d5d69f6..e4090aee 100644
--- a/lib/users/auth/passwordUtil.ts
+++ b/lib/users/auth/passwordUtil.ts
@@ -786,21 +786,21 @@ export async function verifyEmailToken(
)
)
.limit(1);
-
+
if (!mfaToken[0]) {
return { success: false, error: '잘못된 인증번호입니다' };
}
-
+
// 만료 체크
if (mfaToken[0].expiresAt < new Date()) {
await db
.update(mfaTokens)
.set({ isActive: false })
.where(eq(mfaTokens.id, mfaToken[0].id));
-
+
return { success: false, error: '인증번호가 만료되었습니다' };
}
-
+
// 시도 횟수 증가
const newAttempts = mfaToken[0].attempts + 1;
if (newAttempts > 3) {
@@ -808,10 +808,10 @@ export async function verifyEmailToken(
.update(mfaTokens)
.set({ isActive: false })
.where(eq(mfaTokens.id, mfaToken[0].id));
-
+
return { success: false, error: '시도 횟수를 초과했습니다' };
}
-
+
// 토큰 사용 처리
await db
.update(mfaTokens)
@@ -821,15 +821,82 @@ export async function verifyEmailToken(
attempts: newAttempts,
})
.where(eq(mfaTokens.id, mfaToken[0].id));
-
+
return { success: true };
-
+
} catch (error) {
console.error('Email token verification error:', error);
return { success: false, error: '인증 중 오류가 발생했습니다' };
}
}
+// ========== 입찰 공고 알림 SMS 전송 ==========
+
+// 입찰 공고 알림 SMS 템플릿
+const BIDDING_NOTICE_SMS_TEMPLATES = {
+ '82': '[입찰공고] {title} 입찰이 생성되었습니다. 확인 부탁드립니다.', // 한국
+ '1': '[Bidding Notice] {title} bidding has been created. Please check.', // 미국
+ '81': '[入札公告] {title} 入札が作成されました。ご確認ください。', // 일본
+ '86': '[招标公告] {title} 投标已创建。请确认。', // 중국
+ 'default': '[입찰공고] {title} 입찰이 생성되었습니다. 확인 부탁드립니다.' // 기본값 (한국어)
+} as const;
+
+// 입찰 공고 알림용 SMS 메시지 생성
+function getBiddingNoticeSmsMessage(phoneNumber: string, title: string): string {
+ try {
+ const countryInfo = extractCountryInfo(phoneNumber);
+ if (!countryInfo) {
+ return BIDDING_NOTICE_SMS_TEMPLATES.default.replace('{title}', title);
+ }
+
+ const template = BIDDING_NOTICE_SMS_TEMPLATES[countryInfo.countryCode as keyof typeof BIDDING_NOTICE_SMS_TEMPLATES] || BIDDING_NOTICE_SMS_TEMPLATES.default;
+ return template.replace('{title}', title);
+ } catch (error) {
+ return BIDDING_NOTICE_SMS_TEMPLATES.default.replace('{title}', title); // 에러 시 기본값
+ }
+}
+
+// 입찰 공고 알림 SMS 전송
+export async function sendBiddingNoticeSms(
+ phoneNumber: string,
+ biddingTitle: string
+): Promise<{ success: boolean; error?: string }> {
+ try {
+ // 전화번호 유효성 검사
+ if (!isValidPhoneNumber(phoneNumber)) {
+ console.log(`Invalid phone number format: ${phoneNumber}`);
+ return { success: false, error: '유효하지 않은 전화번호입니다' };
+ }
+
+ // 전화번호 정규화
+ const normalizedPhone = normalizePhoneNumber(phoneNumber);
+ if (!normalizedPhone) {
+ console.log(`Failed to normalize phone number: ${phoneNumber}`);
+ return { success: false, error: '전화번호 형식이 올바르지 않습니다' };
+ }
+
+ // 메시지 생성
+ const message = getBiddingNoticeSmsMessage(normalizedPhone, biddingTitle);
+
+ console.log(`Sending bidding notice SMS to ${normalizedPhone}: ${message}`);
+
+ // SMS 전송
+ const smsResult = await sendSmsMessage(normalizedPhone, message);
+
+ if (smsResult) {
+ console.log(`Bidding notice SMS sent successfully to ${normalizedPhone}`);
+ return { success: true };
+ } else {
+ console.error(`Failed to send bidding notice SMS to ${normalizedPhone}`);
+ return { success: false, error: 'SMS 전송에 실패했습니다' };
+ }
+
+ } catch (error) {
+ console.error('Bidding notice SMS send error:', error);
+ return { success: false, error: 'SMS 전송 중 오류가 발생했습니다' };
+ }
+}
+
// 패스워드 강제 변경 필요 체크
export async function checkPasswordChangeRequired(userId: number): Promise<boolean> {
const user = await db