diff options
Diffstat (limited to 'lib/bidding')
| -rw-r--r-- | lib/bidding/actions.ts | 226 | ||||
| -rw-r--r-- | lib/bidding/detail/service.ts | 14 | ||||
| -rw-r--r-- | lib/bidding/detail/table/bidding-detail-vendor-create-dialog.tsx | 4 | ||||
| -rw-r--r-- | lib/bidding/detail/table/bidding-detail-vendor-table.tsx | 32 | ||||
| -rw-r--r-- | lib/bidding/detail/table/bidding-detail-vendor-toolbar-actions.tsx | 68 | ||||
| -rw-r--r-- | lib/bidding/list/biddings-table-toolbar-actions.tsx | 100 | ||||
| -rw-r--r-- | lib/bidding/list/biddings-transmission-dialog.tsx | 159 | ||||
| -rw-r--r-- | lib/bidding/pre-quote/service.ts | 60 | ||||
| -rw-r--r-- | lib/bidding/pre-quote/table/bidding-pre-quote-vendor-create-dialog.tsx | 4 | ||||
| -rw-r--r-- | lib/bidding/service.ts | 55 | ||||
| -rw-r--r-- | lib/bidding/vendor/partners-bidding-detail.tsx | 25 | ||||
| -rw-r--r-- | lib/bidding/vendor/partners-bidding-list-columns.tsx | 7 | ||||
| -rw-r--r-- | lib/bidding/vendor/partners-bidding-pre-quote.tsx | 33 |
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 ( |
