summaryrefslogtreecommitdiff
path: root/lib/bidding
diff options
context:
space:
mode:
authordujinkim <dujin.kim@dtsolution.co.kr>2025-09-16 09:20:58 +0000
committerdujinkim <dujin.kim@dtsolution.co.kr>2025-09-16 09:20:58 +0000
commit6c11fccc84f4c84fa72ee01f9caad9f76f35cea2 (patch)
treefa88d10ea7d21fe6b59ed0c1569856a73d56547a /lib/bidding
parent14e3990aba7e1ad1cdd0965cbd167c50230cbfbf (diff)
(대표님, 최겸) 계약, 업로드 관련, 메뉴처리, 입찰, 프리쿼트, rfqLast관련, tbeLast관련
Diffstat (limited to 'lib/bidding')
-rw-r--r--lib/bidding/actions.ts226
-rw-r--r--lib/bidding/detail/service.ts14
-rw-r--r--lib/bidding/detail/table/bidding-detail-vendor-create-dialog.tsx4
-rw-r--r--lib/bidding/detail/table/bidding-detail-vendor-table.tsx32
-rw-r--r--lib/bidding/detail/table/bidding-detail-vendor-toolbar-actions.tsx68
-rw-r--r--lib/bidding/list/biddings-table-toolbar-actions.tsx100
-rw-r--r--lib/bidding/list/biddings-transmission-dialog.tsx159
-rw-r--r--lib/bidding/pre-quote/service.ts60
-rw-r--r--lib/bidding/pre-quote/table/bidding-pre-quote-vendor-create-dialog.tsx4
-rw-r--r--lib/bidding/service.ts55
-rw-r--r--lib/bidding/vendor/partners-bidding-detail.tsx25
-rw-r--r--lib/bidding/vendor/partners-bidding-list-columns.tsx7
-rw-r--r--lib/bidding/vendor/partners-bidding-pre-quote.tsx33
13 files changed, 654 insertions, 133 deletions
diff --git a/lib/bidding/actions.ts b/lib/bidding/actions.ts
new file mode 100644
index 00000000..9aabd469
--- /dev/null
+++ b/lib/bidding/actions.ts
@@ -0,0 +1,226 @@
+"use server"
+
+import db from "@/db/db"
+import { eq, and } from "drizzle-orm"
+import {
+ biddings,
+ biddingCompanies,
+ prItemsForBidding,
+ vendors,
+ generalContracts,
+ generalContractItems
+} from "@/db/schema"
+import { createPurchaseOrder } from "@/lib/soap/ecc/send/create-po"
+import { getCurrentSAPDate } from "@/lib/soap/utils"
+
+// TO Contract 서버 액션
+export async function transmitToContract(biddingId: number, userId: number) {
+ console.log('=== transmitToContract STARTED ===')
+ console.log('biddingId:', biddingId, 'userId:', userId)
+
+ try {
+ // 1. 입찰 정보 조회 (단순 쿼리)
+ console.log('Querying bidding...')
+ const bidding = await db.select()
+ .from(biddings)
+ .where(eq(biddings.id, biddingId))
+ .limit(1)
+
+ if (!bidding || bidding.length === 0) {
+ throw new Error("입찰 정보를 찾을 수 없습니다.")
+ }
+
+ const biddingData = bidding[0]
+ console.log('biddingData', biddingData)
+
+ // 2. 낙찰된 업체들 조회 (별도 쿼리)
+ console.log('Querying bidding companies...')
+ let winnerCompaniesData = []
+ try {
+ // 2.1 biddingCompanies만 먼저 조회 (join 제거)
+ console.log('Step 1: Querying biddingCompanies only...')
+ const biddingCompaniesRaw = await db.select()
+ .from(biddingCompanies)
+ .where(
+ and(
+ eq(biddingCompanies.biddingId, biddingId),
+ eq(biddingCompanies.isWinner, true)
+ )
+ )
+
+ console.log('biddingCompaniesRaw:', biddingCompaniesRaw)
+
+ // 2.2 각 company에 대한 vendor 정보 개별 조회
+ for (const bc of biddingCompaniesRaw) {
+ console.log('Processing companyId:', bc.companyId)
+
+ try {
+ const vendorData = await db.select()
+ .from(vendors)
+ .where(eq(vendors.id, bc.companyId))
+ .limit(1)
+
+ const vendor = vendorData.length > 0 ? vendorData[0] : null
+ console.log('Vendor data for', bc.companyId, ':', vendor)
+
+ winnerCompaniesData.push({
+ companyId: bc.companyId,
+ finalQuoteAmount: bc.finalQuoteAmount,
+ vendorCode: vendor?.vendorCode || null,
+ vendorName: vendor?.vendorName || null,
+ })
+ } catch (vendorError) {
+ console.error('Vendor query error for', bc.companyId, ':', vendorError)
+ // vendor 정보 없이도 진행
+ winnerCompaniesData.push({
+ companyId: bc.companyId,
+ finalQuoteAmount: bc.finalQuoteAmount,
+ vendorCode: null,
+ vendorName: null,
+ })
+ }
+ }
+
+ console.log('winnerCompaniesData type:', typeof winnerCompaniesData)
+ console.log('winnerCompaniesData length:', winnerCompaniesData?.length)
+ console.log('winnerCompaniesData:', winnerCompaniesData)
+ } catch (queryError) {
+ console.error('Query error:', queryError)
+ throw new Error(`biddingCompanies 쿼리 실패: ${queryError}`)
+ }
+
+ // 상태 검증
+ console.log('biddingData.status', biddingData.status)
+ if (biddingData.status !== 'vendor_selected') {
+ throw new Error("업체 선정이 완료되지 않은 입찰입니다.")
+ }
+
+ // 낙찰된 업체 검증
+ if (winnerCompaniesData.length === 0) {
+ throw new Error("낙찰된 업체가 없습니다.")
+ }
+
+ console.log('Processing', winnerCompaniesData.length, 'winner companies')
+ for (const winnerCompany of winnerCompaniesData) {
+ // 계약 번호 자동 생성 (현재 시간 기반)
+ const contractNumber = `CONTRACT-BID-${Date.now()}-${winnerCompany.companyId}`
+ console.log('contractNumber', contractNumber)
+ // general-contract 생성
+ const contractResult = await db.insert(generalContracts).values({
+ contractNumber,
+ revision: 0,
+ contractSourceType: 'bid', // 입찰에서 생성됨
+ status: 'Draft',
+ category: biddingData.contractType as any, // 단가계약, 일반계약, 매각계약
+ name: biddingData.title,
+ selectionMethod: '입찰',
+ vendorId: winnerCompany.companyId,
+ linkedBidNumber: biddingData.biddingNumber,
+ contractAmount: winnerCompany.finalQuoteAmount || undefined,
+ currency: biddingData.currency || 'KRW',
+ registeredById: userId, // TODO: 현재 사용자 ID로 변경 필요
+ lastUpdatedById: userId, // TODO: 현재 사용자 ID로 변경 필요
+ }).returning({ id: generalContracts.id })
+ console.log('contractResult', contractResult)
+ const contractId = contractResult[0].id
+
+ // 3. PR 아이템들로 general-contract-items 생성 (일단 생략)
+ console.log('Skipping PR items creation for now')
+ }
+
+ return { success: true, message: `${winnerCompaniesData.length}개의 계약서가 생성되었습니다.` }
+
+ } catch (error) {
+ console.error('TO Contract 실패:', error)
+ throw new Error(error instanceof Error ? error.message : '계약서 생성에 실패했습니다.')
+ }
+}
+
+// TO PO 서버 액션
+export async function transmitToPO(biddingId: number) {
+ try {
+ // 1. 입찰 정보 및 낙찰 업체 조회
+ const bidding = await db.query.biddings.findFirst({
+ where: eq(biddings.id, biddingId),
+ with: {
+ biddingCompanies: {
+ where: eq(biddingCompanies.isWinner, true), // 낙찰된 업체만
+ with: {
+ vendor: true
+ }
+ },
+ prItemsForBidding: true
+ }
+ })
+
+ if (!bidding) {
+ throw new Error("입찰 정보를 찾을 수 없습니다.")
+ }
+
+ if (bidding.status !== 'vendor_selected') {
+ throw new Error("업체 선정이 완료되지 않은 입찰입니다.")
+ }
+
+ const winnerCompanies = bidding.biddingCompanies.filter(bc => bc.isWinner)
+
+ if (winnerCompanies.length === 0) {
+ throw new Error("낙찰된 업체가 없습니다.")
+ }
+
+ // 2. PO 데이터 구성
+ const poData = {
+ T_Bidding_HEADER: winnerCompanies.map((company, index) => ({
+ ANFNR: bidding.biddingNumber,
+ LIFNR: company.vendor?.vendorCode || `VENDOR${company.companyId}`,
+ ZPROC_IND: 'A', // 구매 처리 상태
+ ANGNR: bidding.biddingNumber,
+ WAERS: bidding.currency || 'KRW',
+ ZTERM: '0001', // 기본 지급조건
+ INCO1: 'FOB',
+ INCO2: 'Seoul, Korea',
+ MWSKZ: 'V0', // 세금 코드
+ LANDS: 'KR',
+ ZRCV_DT: getCurrentSAPDate(),
+ ZATTEN_IND: 'Y',
+ IHRAN: getCurrentSAPDate(),
+ TEXT: `PO from Bidding: ${bidding.title}`,
+ })),
+ T_Bidding_ITEM: bidding.prItemsForBidding?.map((item, index) => ({
+ ANFNR: bidding.biddingNumber,
+ ANFPS: (index + 1).toString().padStart(5, '0'),
+ LIFNR: winnerCompanies[0]?.vendor?.vendorCode || `VENDOR${winnerCompanies[0]?.companyId}`,
+ NETPR: item.annualUnitPrice?.toString() || '0',
+ PEINH: '1',
+ BPRME: item.quantityUnit || 'EA',
+ NETWR: item.annualUnitPrice && item.quantity
+ ? (item.annualUnitPrice * item.quantity).toString()
+ : '0',
+ BRTWR: item.annualUnitPrice && item.quantity
+ ? ((item.annualUnitPrice * item.quantity) * 1.1).toString() // 10% 부가세 가정
+ : '0',
+ LFDAT: item.requestedDeliveryDate?.toISOString().split('T')[0] || getCurrentSAPDate(),
+ })) || [],
+ T_PR_RETURN: [{
+ ANFNR: bidding.biddingNumber,
+ ANFPS: '00001',
+ EBELN: `PR${bidding.biddingNumber}`,
+ EBELP: '00001',
+ MSGTY: 'S',
+ MSGTXT: 'Success'
+ }]
+ }
+
+ // 3. SAP으로 PO 전송
+ const result = await createPurchaseOrder(poData)
+
+ if (!result.success) {
+ throw new Error(result.message)
+ }
+
+ return { success: true, message: result.message }
+
+ } catch (error) {
+ console.error('TO PO 실패:', error)
+ throw new Error(error instanceof Error ? error.message : 'PO 전송에 실패했습니다.')
+ }
+}
diff --git a/lib/bidding/detail/service.ts b/lib/bidding/detail/service.ts
index df8427da..b00a4f4f 100644
--- a/lib/bidding/detail/service.ts
+++ b/lib/bidding/detail/service.ts
@@ -632,7 +632,7 @@ export async function createBiddingDetailVendor(
companyId: vendorId,
invitationStatus: 'pending',
isPreQuoteSelected: true, // 본입찰 등록 기본값
- isWinner: false,
+ isWinner: null, // 미정 상태로 초기화 0916
createdAt: new Date(),
updatedAt: new Date(),
}).returning({ id: biddingCompanies.id })
@@ -679,13 +679,23 @@ export async function createQuotationVendor(input: any, userId: string) {
try {
const userName = await getUserNameById(userId)
const result = await db.transaction(async (tx) => {
+ // 0. 중복 체크 - 이미 해당 입찰에 참여중인 업체인지 확인
+ const existingCompany = await tx
+ .select()
+ .from(biddingCompanies)
+ .where(sql`${biddingCompanies.biddingId} = ${input.biddingId} AND ${biddingCompanies.companyId} = ${input.companyId}`)
+
+ if (existingCompany.length > 0) {
+ throw new Error('이미 등록된 업체입니다')
+ }
+
// 1. biddingCompanies에 레코드 생성
const biddingCompanyResult = await tx.insert(biddingCompanies).values({
biddingId: input.biddingId,
companyId: input.vendorId,
finalQuoteAmount: input.quotationAmount?.toString(),
awardRatio: input.awardRatio?.toString(),
- isWinner: false,
+ isWinner: null, // 미정 상태로 초기화
contactPerson: input.contactPerson,
contactEmail: input.contactEmail,
contactPhone: input.contactPhone,
diff --git a/lib/bidding/detail/table/bidding-detail-vendor-create-dialog.tsx b/lib/bidding/detail/table/bidding-detail-vendor-create-dialog.tsx
index 5e85af06..f35957bc 100644
--- a/lib/bidding/detail/table/bidding-detail-vendor-create-dialog.tsx
+++ b/lib/bidding/detail/table/bidding-detail-vendor-create-dialog.tsx
@@ -36,7 +36,7 @@ import {
import { Check, ChevronsUpDown, Search } from 'lucide-react'
import { cn } from '@/lib/utils'
import { createBiddingDetailVendor } from '@/lib/bidding/detail/service'
-import { searchVendors } from '@/lib/vendors/service'
+import { searchVendorsForBidding } from '@/lib/bidding/service'
import { useToast } from '@/hooks/use-toast'
import { useTransition } from 'react'
@@ -83,7 +83,7 @@ export function BiddingDetailVendorCreateDialog({
}
try {
- const result = await searchVendors(vendorSearchValue.trim(), 10)
+ const result = await searchVendorsForBidding(vendorSearchValue.trim(), biddingId, 10)
setVendors(result)
} catch (error) {
console.error('Vendor search failed:', error)
diff --git a/lib/bidding/detail/table/bidding-detail-vendor-table.tsx b/lib/bidding/detail/table/bidding-detail-vendor-table.tsx
index 95f63ce9..a9778636 100644
--- a/lib/bidding/detail/table/bidding-detail-vendor-table.tsx
+++ b/lib/bidding/detail/table/bidding-detail-vendor-table.tsx
@@ -20,7 +20,7 @@ import {
} from '@/lib/bidding/detail/service'
import { selectWinnerSchema } from '@/lib/bidding/validation'
import { useToast } from '@/hooks/use-toast'
-import { useTransition } from 'react'
+import { useTransition, useCallback } from 'react'
interface BiddingDetailVendorTableContentProps {
biddingId: number
@@ -114,29 +114,7 @@ export function BiddingDetailVendorTableContent({
const [priceAdjustmentData, setPriceAdjustmentData] = React.useState<any>(null)
const [isPriceAdjustmentDialogOpen, setIsPriceAdjustmentDialogOpen] = React.useState(false)
- const handleDelete = (vendor: QuotationVendor) => {
- if (!confirm(`${vendor.vendorName} 업체를 삭제하시겠습니까?`)) return
-
- startTransition(async () => {
- const response = await deleteQuotationVendor(vendor.id)
-
- if (response.success) {
- toast({
- title: '성공',
- description: response.message,
- })
- onRefresh()
- } else {
- toast({
- title: '오류',
- description: response.error,
- variant: 'destructive',
- })
- }
- })
- }
-
- const handleSelectWinner = (vendor: QuotationVendor) => {
+ const handleSelectWinner = useCallback((vendor: QuotationVendor) => {
if (!vendor.awardRatio || vendor.awardRatio <= 0) {
toast({
title: '오류',
@@ -180,7 +158,7 @@ export function BiddingDetailVendorTableContent({
})
}
})
- }
+ }, [toast, startTransition, biddingId, userId, selectWinnerSchema, selectWinner, onRefresh])
const handleEdit = (vendor: QuotationVendor) => {
setSelectedVendor(vendor)
@@ -214,12 +192,12 @@ export function BiddingDetailVendorTableContent({
const columns = React.useMemo(
() => getBiddingDetailVendorColumns({
onEdit: onEdit || handleEdit,
- onDelete: onDelete || handleDelete,
+ onDelete: onDelete,
onSelectWinner: onSelectWinner || handleSelectWinner,
onViewPriceAdjustment: handleViewPriceAdjustment,
onViewItemDetails: onViewItemDetails
}),
- [onEdit, onDelete, onSelectWinner, handleEdit, handleDelete, handleSelectWinner, handleViewPriceAdjustment, onViewItemDetails]
+ [onEdit, onDelete, onSelectWinner, handleEdit, handleSelectWinner, handleViewPriceAdjustment, onViewItemDetails]
)
const { table } = useDataTable({
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 0b707944..893fb185 100644
--- a/lib/bidding/detail/table/bidding-detail-vendor-toolbar-actions.tsx
+++ b/lib/bidding/detail/table/bidding-detail-vendor-toolbar-actions.tsx
@@ -180,6 +180,8 @@ export function BiddingDetailVendorToolbarActions({
<>
<div className="flex items-center gap-2">
{/* 상태별 액션 버튼 */}
+ {bidding.status !== 'bidding_closed' && bidding.status !== 'vendor_selected' && (
+ <>
<Button
variant="default"
size="sm"
@@ -223,7 +225,6 @@ export function BiddingDetailVendorToolbarActions({
{/* 구분선 */}
{(bidding.status === 'bidding_generated' ||
- bidding.status === 'bidding_closed' ||
bidding.status === 'bidding_disposal') && (
<div className="h-4 w-px bg-border mx-1" />
)}
@@ -236,37 +237,40 @@ export function BiddingDetailVendorToolbarActions({
>
품목 정보
</Button> */}
- <Button
- variant="outline"
- size="sm"
- onClick={onOpenTargetPriceDialog}
- >
- 내정가 산정
- </Button>
- <Button
- variant="outline"
- size="sm"
- onClick={handleCreateVendor}
- >
- <Plus className="mr-2 h-4 w-4" />
- 업체 추가
- </Button>
- <Button
- variant="outline"
- size="sm"
- onClick={handleDocumentUpload}
- >
- <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>
+
+ <Button
+ variant="outline"
+ size="sm"
+ onClick={onOpenTargetPriceDialog}
+ >
+ 내정가 산정
+ </Button>
+ <Button
+ variant="outline"
+ size="sm"
+ onClick={handleCreateVendor}
+ >
+ <Plus className="mr-2 h-4 w-4" />
+ 업체 추가
+ </Button>
+ <Button
+ variant="outline"
+ size="sm"
+ onClick={handleDocumentUpload}
+ >
+ <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
diff --git a/lib/bidding/list/biddings-table-toolbar-actions.tsx b/lib/bidding/list/biddings-table-toolbar-actions.tsx
index 2b7a9d7d..ed5538c6 100644
--- a/lib/bidding/list/biddings-table-toolbar-actions.tsx
+++ b/lib/bidding/list/biddings-table-toolbar-actions.tsx
@@ -2,12 +2,12 @@
import * as React from "react"
import { type Table } from "@tanstack/react-table"
-import {
- Plus, Send, Gavel, Download, FileSpreadsheet,
- Eye, Clock, CheckCircle
+import {
+ Plus, Send, Download, FileSpreadsheet
} from "lucide-react"
import { toast } from "sonner"
import { useRouter } from "next/navigation"
+import { useSession } from "next-auth/react"
import { exportTableToExcel } from "@/lib/export"
import { Button } from "@/components/ui/button"
import {
@@ -19,6 +19,7 @@ import {
} from "@/components/ui/dropdown-menu"
import { BiddingListItem } from "@/db/schema"
import { CreateBiddingDialog } from "./create-bidding-dialog"
+import { TransmissionDialog } from "./biddings-transmission-dialog"
interface BiddingsTableToolbarActionsProps {
table: Table<BiddingListItem>
@@ -28,7 +29,11 @@ interface BiddingsTableToolbarActionsProps {
export function BiddingsTableToolbarActions({ table, paymentTermsOptions, incotermsOptions }: BiddingsTableToolbarActionsProps) {
const router = useRouter()
+ const { data: session } = useSession()
const [isExporting, setIsExporting] = React.useState(false)
+ const [isTransmissionDialogOpen, setIsTransmissionDialogOpen] = React.useState(false)
+
+ const userId = session?.user?.id ? Number(session.user.id) : 1
// 선택된 입찰들
const selectedBiddings = React.useMemo(() => {
@@ -38,6 +43,9 @@ export function BiddingsTableToolbarActions({ table, paymentTermsOptions, incote
.map(row => row.original)
}, [table.getFilteredSelectedRowModel().rows])
+ // 업체선정이 완료된 입찰만 전송 가능
+ const canTransmit = selectedBiddings.length === 1 && selectedBiddings[0].status === 'vendor_selected'
+
const handleExport = async () => {
try {
setIsExporting(true)
@@ -54,47 +62,69 @@ export function BiddingsTableToolbarActions({ table, paymentTermsOptions, incote
}
return (
- <div className="flex items-center gap-2">
- {/* 신규 생성 */}
- <CreateBiddingDialog
- paymentTermsOptions={paymentTermsOptions}
- incotermsOptions={incotermsOptions}
- />
+ <>
+ <div className="flex items-center gap-2">
+ {/* 신규 생성 */}
+ <CreateBiddingDialog
+ paymentTermsOptions={paymentTermsOptions}
+ incotermsOptions={incotermsOptions}
+ />
- {/* 개찰 (입찰 오픈) */}
- {/* {openEligibleBiddings.length > 0 && (
- <Button
- variant="outline"
+ {/* 전송하기 (업체선정 완료된 입찰만) */}
+ <Button
+ variant="default"
size="sm"
- onClick={handleBiddingOpen}
+ onClick={() => setIsTransmissionDialogOpen(true)}
+ disabled={!canTransmit}
+ className="gap-2"
>
- <Gavel className="mr-2 h-4 w-4" />
- 개찰 ({openEligibleBiddings.length})
+ <Send className="size-4" aria-hidden="true" />
+ <span className="hidden sm:inline">전송하기</span>
</Button>
- )} */}
- {/* Export */}
- <DropdownMenu>
- <DropdownMenuTrigger asChild>
+ {/* 개찰 (입찰 오픈) */}
+ {/* {openEligibleBiddings.length > 0 && (
<Button
variant="outline"
size="sm"
- className="gap-2"
- disabled={isExporting}
+ onClick={handleBiddingOpen}
>
- <Download className="size-4" aria-hidden="true" />
- <span className="hidden sm:inline">
- {isExporting ? "내보내는 중..." : "Export"}
- </span>
+ <Gavel className="mr-2 h-4 w-4" />
+ 개찰 ({openEligibleBiddings.length})
</Button>
- </DropdownMenuTrigger>
- <DropdownMenuContent align="end">
- <DropdownMenuItem onClick={handleExport} disabled={isExporting}>
- <FileSpreadsheet className="mr-2 size-4" />
- <span>입찰 목록 내보내기</span>
- </DropdownMenuItem>
- </DropdownMenuContent>
- </DropdownMenu>
- </div>
+ )} */}
+
+ {/* Export */}
+ <DropdownMenu>
+ <DropdownMenuTrigger asChild>
+ <Button
+ variant="outline"
+ size="sm"
+ className="gap-2"
+ disabled={isExporting}
+ >
+ <Download className="size-4" aria-hidden="true" />
+ <span className="hidden sm:inline">
+ {isExporting ? "내보내는 중..." : "Export"}
+ </span>
+ </Button>
+ </DropdownMenuTrigger>
+ <DropdownMenuContent align="end">
+ <DropdownMenuItem onClick={handleExport} disabled={isExporting}>
+ <FileSpreadsheet className="mr-2 size-4" />
+ <span>입찰 목록 내보내기</span>
+ </DropdownMenuItem>
+ </DropdownMenuContent>
+ </DropdownMenu>
+ </div>
+
+ {/* 전송 다이얼로그 */}
+ <TransmissionDialog
+ open={isTransmissionDialogOpen}
+ onOpenChange={setIsTransmissionDialogOpen}
+ bidding={selectedBiddings[0]}
+ userId={userId}
+ />
+ </>
)
} \ No newline at end of file
diff --git a/lib/bidding/list/biddings-transmission-dialog.tsx b/lib/bidding/list/biddings-transmission-dialog.tsx
new file mode 100644
index 00000000..d307ec9d
--- /dev/null
+++ b/lib/bidding/list/biddings-transmission-dialog.tsx
@@ -0,0 +1,159 @@
+"use client"
+
+import * as React from "react"
+import {
+ Send, CheckCircle, FileText, Truck
+} from "lucide-react"
+import { toast } from "sonner"
+import { Button } from "@/components/ui/button"
+import {
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogFooter,
+ DialogHeader,
+ DialogTitle,
+} from "@/components/ui/dialog"
+import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
+import { Label } from "@/components/ui/label"
+import { BiddingListItem } from "@/db/schema"
+import { transmitToContract, transmitToPO } from "@/lib/bidding/actions"
+
+console.log('=== Module loaded ===')
+console.log('transmitToContract imported:', typeof transmitToContract)
+console.log('transmitToPO imported:', typeof transmitToPO)
+
+interface TransmissionDialogProps {
+ open: boolean
+ onOpenChange: (open: boolean) => void
+ bidding: BiddingListItem | undefined
+ userId: number
+}
+
+export function TransmissionDialog({ open, onOpenChange, bidding, userId }: TransmissionDialogProps) {
+ const [isLoading, setIsLoading] = React.useState(false)
+
+ if (!bidding) return null
+
+ const handleToContract = async () => {
+ try {
+ setIsLoading(true)
+ console.log('=== START handleToContract ===')
+ console.log('bidding.id', bidding.id)
+ console.log('userId', userId)
+ console.log('transmitToContract function:', typeof transmitToContract)
+
+ console.log('About to call transmitToContract...')
+ const result = await transmitToContract(bidding.id, userId)
+ console.log('transmitToContract result:', result)
+
+ toast.success('계약서 생성이 완료되었습니다.')
+ onOpenChange(false)
+ } catch (error) {
+ console.error('handleToContract error:', error)
+ toast.error(`계약서 생성에 실패했습니다: ${error}`)
+ } finally {
+ setIsLoading(false)
+ console.log('=== END handleToContract ===')
+ }
+ }
+
+ const handleToPO = async () => {
+ try {
+ setIsLoading(true)
+ await transmitToPO(bidding.id)
+ toast.success('PO 전송이 완료되었습니다.')
+ onOpenChange(false)
+ } catch (error) {
+ toast.error(`PO 전송에 실패했습니다: ${error}`)
+ } finally {
+ setIsLoading(false)
+ }
+ }
+
+ return (
+ <Dialog open={open} onOpenChange={onOpenChange}>
+ <DialogContent className="sm:max-w-[600px]">
+ <DialogHeader>
+ <DialogTitle className="flex items-center gap-2">
+ <Send className="w-5 h-5" />
+ 입찰 전송
+ </DialogTitle>
+ <DialogDescription>
+ 선택된 입찰을 계약서 또는 PO로 전송합니다.
+ </DialogDescription>
+ </DialogHeader>
+
+ <div className="space-y-6">
+ {/* 입찰 정보 */}
+ <Card>
+ <CardHeader className="pb-3">
+ <CardTitle className="text-base">입찰 정보</CardTitle>
+ </CardHeader>
+ <CardContent className="space-y-2">
+ <div className="grid grid-cols-2 gap-4">
+ <div>
+ <Label className="text-sm font-medium">입찰번호</Label>
+ <p className="text-sm text-muted-foreground">{bidding.biddingNumber}</p>
+ </div>
+ <div>
+ <Label className="text-sm font-medium">입찰명</Label>
+ <p className="text-sm text-muted-foreground">{bidding.title}</p>
+ </div>
+ <div>
+ <Label className="text-sm font-medium">계약구분</Label>
+ <p className="text-sm text-muted-foreground">{bidding.contractType}</p>
+ </div>
+ <div>
+ <Label className="text-sm font-medium">예산</Label>
+ <p className="text-sm text-muted-foreground">
+ {bidding.budget ? `${bidding.budget.toLocaleString()} ${bidding.currency}` : '-'}
+ </p>
+ </div>
+ </div>
+ </CardContent>
+ </Card>
+
+ {/* 선정된 업체 정보 (임시로 표시) */}
+ <Card>
+ <CardHeader className="pb-3">
+ <CardTitle className="text-base">선정된 업체</CardTitle>
+ </CardHeader>
+ <CardContent>
+ <div className="flex items-center gap-2">
+ <CheckCircle className="w-4 h-4 text-green-600" />
+ <span className="text-sm">업체 선정이 완료되었습니다.</span>
+ </div>
+ <p className="text-xs text-muted-foreground mt-2">
+ 자세한 업체 정보는 전송 후 확인할 수 있습니다.
+ </p>
+ </CardContent>
+ </Card>
+ </div>
+
+ <DialogFooter className="flex gap-2">
+ <Button variant="outline" onClick={() => onOpenChange(false)}>
+ 취소
+ </Button>
+ <Button
+ variant="outline"
+ onClick={handleToContract}
+ disabled={isLoading}
+ className="gap-2"
+ >
+ <FileText className="w-4 h-4" />
+ TO Contract
+ </Button>
+ <Button
+ onClick={handleToPO}
+ disabled={isLoading}
+ className="gap-2"
+ >
+ <Truck className="w-4 h-4" />
+ TO PO
+ </Button>
+ </DialogFooter>
+ </DialogContent>
+ </Dialog>
+ )
+}
diff --git a/lib/bidding/pre-quote/service.ts b/lib/bidding/pre-quote/service.ts
index 7f054a66..cad77a6b 100644
--- a/lib/bidding/pre-quote/service.ts
+++ b/lib/bidding/pre-quote/service.ts
@@ -6,12 +6,12 @@ import { basicContractTemplates } from '@/db/schema'
import { vendors } from '@/db/schema/vendors'
import { users } from '@/db/schema'
import { sendEmail } from '@/lib/mail/sendEmail'
-import { eq, inArray, and, ilike } from 'drizzle-orm'
+import { eq, inArray, and, ilike, sql } from 'drizzle-orm'
import { mkdir, writeFile } from 'fs/promises'
import path from 'path'
import { revalidateTag, revalidatePath } from 'next/cache'
import { basicContract } from '@/db/schema/basicContractDocumnet'
-import { saveFile ,saveBuffer} from '@/lib/file-stroage'
+import { saveFile } from '@/lib/file-stroage'
// userId를 user.name으로 변환하는 유틸리티 함수
async function getUserNameById(userId: string): Promise<string> {
@@ -69,6 +69,15 @@ interface PreQuoteDocumentUpload {
export async function createBiddingCompany(input: CreateBiddingCompanyInput) {
try {
const result = await db.transaction(async (tx) => {
+ // 0. 중복 체크 - 이미 해당 입찰에 참여중인 업체인지 확인
+ const existingCompany = await tx
+ .select()
+ .from(biddingCompanies)
+ .where(sql`${biddingCompanies.biddingId} = ${input.biddingId} AND ${biddingCompanies.companyId} = ${input.companyId}`)
+
+ if (existingCompany.length > 0) {
+ throw new Error('이미 등록된 업체입니다')
+ }
// 1. biddingCompanies 레코드 생성
const biddingCompanyResult = await tx.insert(biddingCompanies).values({
biddingId: input.biddingId,
@@ -1225,7 +1234,10 @@ export async function sendBiddingBasicContracts(
const results = []
const savedContracts = []
- // 트랜잭션 시작 - contractsDir 제거 (saveBuffer가 처리)
+ // 트랜잭션 시작
+ const contractsDir = path.join(process.cwd(), `${process.env.NAS_PATH}`, "contracts", "generated");
+ await mkdir(contractsDir, { recursive: true });
+
const result = await db.transaction(async (tx) => {
// 각 벤더별로 기본계약 생성 및 이메일 발송
for (const vendor of vendorData) {
@@ -1285,7 +1297,6 @@ export async function sendBiddingBasicContracts(
if (vendor.contractRequirements.projectGtcYn) contractTypes.push({ type: 'Project_GTC', templateName: '기술' })
if (vendor.contractRequirements.agreementYn) contractTypes.push({ type: '기술자료', templateName: '기술자료' })
console.log("contractTypes", contractTypes)
-
for (const contractType of contractTypes) {
// PDF 데이터 찾기 (include를 사용하여 유연하게 찾기)
console.log("generatedPdfs", generatedPdfs.map(pdf => pdf.key))
@@ -1299,22 +1310,11 @@ export async function sendBiddingBasicContracts(
continue
}
- // 파일 저장 - saveBuffer 사용
+ // 파일 저장 (rfq-last 방식)
const fileName = `${contractType.type}_${vendor.vendorCode || vendor.vendorId}_${vendor.biddingCompanyId}_${Date.now()}.pdf`
-
- const saveResult = await saveBuffer({
- buffer: Buffer.from(pdfData.buffer),
- fileName: fileName,
- directory: 'contracts/generated',
- originalName: fileName,
- userId: currentUser.id
- })
+ const filePath = path.join(contractsDir, fileName);
- // 저장 실패 시 처리
- if (!saveResult.success) {
- console.error(`PDF 저장 실패: ${saveResult.error}`)
- continue
- }
+ await writeFile(filePath, Buffer.from(pdfData.buffer));
// 템플릿 정보 조회 (rfq-last 방식)
const [template] = await db
@@ -1352,8 +1352,8 @@ export async function sendBiddingBasicContracts(
.set({
requestedBy: currentUser.id,
status: "PENDING", // 재발송 상태
- fileName: saveResult.originalName || fileName, // 원본 파일명
- filePath: saveResult.publicPath, // saveBuffer가 반환한 공개 경로
+ fileName: fileName,
+ filePath: `/contracts/generated/${fileName}`,
deadline: new Date(Date.now() + 10 * 24 * 60 * 60 * 1000),
updatedAt: new Date(),
})
@@ -1373,8 +1373,8 @@ export async function sendBiddingBasicContracts(
generalContractId: null,
requestedBy: currentUser.id,
status: 'PENDING',
- fileName: saveResult.originalName || fileName, // 원본 파일명
- filePath: saveResult.publicPath, // saveBuffer가 반환한 공개 경로
+ fileName: fileName,
+ filePath: `/contracts/generated/${fileName}`,
deadline: new Date(Date.now() + 10 * 24 * 60 * 60 * 1000), // 10일 후
createdAt: new Date(),
updatedAt: new Date(),
@@ -1389,10 +1389,19 @@ export async function sendBiddingBasicContracts(
vendorName: vendor.vendorName,
contractId: contractRecord.id,
contractType: contractType.type,
- fileName: saveResult.originalName || fileName,
- filePath: saveResult.publicPath,
- hashedFileName: saveResult.fileName, // 실제 저장된 파일명 (디버깅용)
+ fileName: fileName,
+ filePath: `/contracts/generated/${fileName}`,
})
+
+ // savedContracts에 추가 (rfq-last 방식)
+ // savedContracts.push({
+ // vendorId: vendor.vendorId,
+ // vendorName: vendor.vendorName,
+ // templateName: contractType.templateName,
+ // contractId: contractRecord.id,
+ // fileName: fileName,
+ // isUpdated: !!existingContract, // 업데이트 여부 표시
+ // })
}
// 이메일 발송 (선택사항)
@@ -1439,6 +1448,7 @@ export async function sendBiddingBasicContracts(
)
}
}
+
// 기존 기본계약 조회 (서버 액션)
export async function getExistingBasicContractsForBidding(biddingId: number) {
try {
diff --git a/lib/bidding/pre-quote/table/bidding-pre-quote-vendor-create-dialog.tsx b/lib/bidding/pre-quote/table/bidding-pre-quote-vendor-create-dialog.tsx
index e2a38547..bc233e77 100644
--- a/lib/bidding/pre-quote/table/bidding-pre-quote-vendor-create-dialog.tsx
+++ b/lib/bidding/pre-quote/table/bidding-pre-quote-vendor-create-dialog.tsx
@@ -26,7 +26,7 @@ import {
import { Check, ChevronsUpDown } from 'lucide-react'
import { cn } from '@/lib/utils'
import { createBiddingCompany } from '@/lib/bidding/pre-quote/service'
-import { searchVendors } from '@/lib/vendors/service'
+import { searchVendorsForBidding } from '@/lib/bidding/service'
import { useToast } from '@/hooks/use-toast'
import { useTransition } from 'react'
@@ -69,7 +69,7 @@ export function BiddingPreQuoteVendorCreateDialog({
}
try {
- const result = await searchVendors(vendorSearchValue.trim(), 10)
+ const result = await searchVendorsForBidding(vendorSearchValue.trim(), biddingId, 10)
setVendors(result)
} catch (error) {
console.error('Vendor search failed:', error)
diff --git a/lib/bidding/service.ts b/lib/bidding/service.ts
index 90a379e1..55146c4b 100644
--- a/lib/bidding/service.ts
+++ b/lib/bidding/service.ts
@@ -14,7 +14,10 @@ import {
users,
basicContractTemplates,
paymentTerms,
- incoterms
+ incoterms,
+ vendors,
+ vendorsWithTypesView,
+ biddingCompanies
} from '@/db/schema'
import {
eq,
@@ -556,6 +559,7 @@ export async function createBidding(input: CreateBiddingInput, userId: string) {
finalBidPrice: input.finalBidPrice ? parseFloat(input.finalBidPrice) : null,
status: input.status || 'bidding_generated',
+ // biddingSourceType: input.biddingSourceType || 'manual',
isPublic: input.isPublic || false,
isUrgent: input.isUrgent || false,
managerName: input.managerName,
@@ -1421,4 +1425,53 @@ export async function getActiveContractTemplates() {
console.error('활성 템플릿 조회 실패:', error);
throw new Error('템플릿 조회에 실패했습니다.');
}
+}
+
+// 입찰에 참여하지 않은 벤더만 검색 (중복 방지)
+export async function searchVendorsForBidding(searchTerm: string = "", biddingId: number, limit: number = 100) {
+ try {
+ let whereCondition;
+
+ if (searchTerm.trim()) {
+ const s = `%${searchTerm.trim()}%`;
+ whereCondition = or(
+ ilike(vendorsWithTypesView.vendorName, s),
+ ilike(vendorsWithTypesView.vendorCode, s)
+ );
+ }
+
+ // 이미 해당 입찰에 참여중인 벤더 ID들을 가져옴
+ const participatingVendorIds = await db
+ .select({ companyId: biddingCompanies.companyId })
+ .from(biddingCompanies)
+ .where(eq(biddingCompanies.biddingId, biddingId));
+
+ const excludedIds = participatingVendorIds.map(p => p.companyId);
+
+ const result = await db
+ .select({
+ id: vendorsWithTypesView.id,
+ vendorName: vendorsWithTypesView.vendorName,
+ vendorCode: vendorsWithTypesView.vendorCode,
+ status: vendorsWithTypesView.status,
+ country: vendorsWithTypesView.country,
+ })
+ .from(vendorsWithTypesView)
+ .where(
+ and(
+ whereCondition,
+ // 이미 참여중인 벤더 제외
+ excludedIds.length > 0 ? sql`${vendorsWithTypesView.id} NOT IN (${excludedIds})` : undefined,
+ // ACTIVE 상태인 벤더만 검색
+ // eq(vendorsWithTypesView.status, "ACTIVE"),
+ )
+ )
+ .orderBy(asc(vendorsWithTypesView.vendorName))
+ .limit(limit);
+
+ return result;
+ } catch (error) {
+ console.error('Error searching vendors for bidding:', error)
+ return []
+ }
} \ No newline at end of file
diff --git a/lib/bidding/vendor/partners-bidding-detail.tsx b/lib/bidding/vendor/partners-bidding-detail.tsx
index 4b316eee..89ca426b 100644
--- a/lib/bidding/vendor/partners-bidding-detail.tsx
+++ b/lib/bidding/vendor/partners-bidding-detail.tsx
@@ -296,6 +296,19 @@ export function PartnersBiddingDetail({ biddingId, companyId }: PartnersBiddingD
const handleSaveDraft = async () => {
if (!biddingDetail || !userId) return
+ // 입찰 마감 상태 체크
+ const biddingStatus = biddingDetail.status
+ const isClosed = biddingStatus === 'bidding_closed' || biddingStatus === 'vendor_selected' || biddingStatus === 'bidding_disposal'
+
+ if (isClosed) {
+ toast({
+ title: "접근 제한",
+ description: "입찰이 마감되어 더 이상 입찰에 참여할 수 없습니다.",
+ variant: "destructive",
+ })
+ return
+ }
+
if (prItemQuotations.length === 0) {
toast({
title: '저장할 데이터 없음',
@@ -350,6 +363,18 @@ export function PartnersBiddingDetail({ biddingId, companyId }: PartnersBiddingD
const handleSubmitResponse = () => {
if (!biddingDetail) return
+ // 입찰 마감 상태 체크
+ const biddingStatus = biddingDetail.status
+ const isClosed = biddingStatus === 'bidding_closed' || biddingStatus === 'vendor_selected' || biddingStatus === 'bidding_disposal'
+
+ if (isClosed) {
+ toast({
+ title: "접근 제한",
+ description: "입찰이 마감되어 더 이상 입찰에 참여할 수 없습니다.",
+ variant: "destructive",
+ })
+ return
+ }
// 필수값 검증
if (!responseData.finalQuoteAmount.trim()) {
diff --git a/lib/bidding/vendor/partners-bidding-list-columns.tsx b/lib/bidding/vendor/partners-bidding-list-columns.tsx
index 431f7e9a..534e8838 100644
--- a/lib/bidding/vendor/partners-bidding-list-columns.tsx
+++ b/lib/bidding/vendor/partners-bidding-list-columns.tsx
@@ -177,7 +177,10 @@ export function getPartnersBiddingListColumns({ setRowAction }: PartnersBiddingL
})
}
}
-
+
+ const biddingStatus = row.original.status
+ const isClosed = biddingStatus === 'bidding_closed' || biddingStatus === 'vendor_selected' || biddingStatus === 'bidding_disposal'
+
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
@@ -194,10 +197,12 @@ export function getPartnersBiddingListColumns({ setRowAction }: PartnersBiddingL
<FileText className="mr-2 h-4 w-4" />
입찰 상세보기
</DropdownMenuItem>
+ {!isClosed && (
<DropdownMenuItem onClick={handlePreQuote}>
<Calculator className="mr-2 h-4 w-4" />
사전견적하기
</DropdownMenuItem>
+ )}
</DropdownMenuContent>
</DropdownMenu>
)
diff --git a/lib/bidding/vendor/partners-bidding-pre-quote.tsx b/lib/bidding/vendor/partners-bidding-pre-quote.tsx
index bdc860f4..4ec65413 100644
--- a/lib/bidding/vendor/partners-bidding-pre-quote.tsx
+++ b/lib/bidding/vendor/partners-bidding-pre-quote.tsx
@@ -254,6 +254,19 @@ export function PartnersBiddingPreQuote({ biddingId, companyId }: PartnersBiddin
})
return
}
+ // 입찰 마감 상태 체크
+ const biddingStatus = biddingDetail.status
+ const isClosed = biddingStatus === 'bidding_closed' || biddingStatus === 'vendor_selected' || biddingStatus === 'bidding_disposal'
+
+ if (isClosed) {
+ toast({
+ title: "접근 제한",
+ description: "입찰이 마감되어 더 이상 사전견적을 제출할 수 없습니다.",
+ variant: "destructive",
+ })
+ router.back()
+ return
+ }
if (!userId) {
toast({
@@ -356,6 +369,20 @@ export function PartnersBiddingPreQuote({ biddingId, companyId }: PartnersBiddin
const handleSubmitResponse = () => {
if (!biddingDetail) return
+ // 입찰 마감 상태 체크
+ const biddingStatus = biddingDetail.status
+ const isClosed = biddingStatus === 'bidding_closed' || biddingStatus === 'vendor_selected' || biddingStatus === 'bidding_disposal'
+
+ if (isClosed) {
+ toast({
+ title: "접근 제한",
+ description: "입찰이 마감되어 더 이상 사전견적을 제출할 수 없습니다.",
+ variant: "destructive",
+ })
+ router.back()
+ return
+ }
+
// 견적마감일 체크
if (biddingDetail.preQuoteDeadline) {
const now = new Date()
@@ -519,12 +546,6 @@ export function PartnersBiddingPreQuote({ biddingId, companyId }: PartnersBiddin
})
}
- const formatCurrency = (amount: number) => {
- return new Intl.NumberFormat('ko-KR', {
- style: 'currency',
- currency: biddingDetail?.currency || 'KRW',
- }).format(amount)
- }
if (isLoading) {
return (