diff options
Diffstat (limited to 'lib')
23 files changed, 3152 insertions, 602 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 ( diff --git a/lib/general-contracts/service.ts b/lib/general-contracts/service.ts index 7f95ae3d..2079a0be 100644 --- a/lib/general-contracts/service.ts +++ b/lib/general-contracts/service.ts @@ -363,6 +363,7 @@ export async function createContract(data: Record<string, unknown>) { .values({
contractNumber: contractNumber,
revision: 0,
+ // contractSourceType: data.contractSourceType || 'manual',
status: data.status || 'Draft',
category: data.category as string,
type: data.type as string,
diff --git a/lib/mail/templates/document-share.hbs b/lib/mail/templates/document-share.hbs new file mode 100644 index 00000000..b3214620 --- /dev/null +++ b/lib/mail/templates/document-share.hbs @@ -0,0 +1,216 @@ +{{!-- templates/document-share.hbs --}} +<!DOCTYPE html> +<html lang="ko"> +<head> + <meta charset="UTF-8"> + <meta name="viewport" content="width=device-width, initial-scale=1.0"> + <title>TBE 문서 공유</title> + <style> + body { + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif; + line-height: 1.6; + color: #333; + max-width: 600px; + margin: 0 auto; + padding: 20px; + background-color: #f5f5f5; + } + .container { + background-color: #ffffff; + border-radius: 8px; + padding: 30px; + box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1); + } + .header { + border-bottom: 2px solid #4F46E5; + padding-bottom: 20px; + margin-bottom: 30px; + } + .logo { + font-size: 24px; + font-weight: bold; + color: #4F46E5; + } + .title { + font-size: 20px; + color: #111827; + margin-top: 10px; + } + .session-info { + background-color: #F3F4F6; + border-radius: 6px; + padding: 15px; + margin: 20px 0; + } + .session-info-item { + display: flex; + justify-content: space-between; + margin: 8px 0; + } + .session-info-label { + font-weight: 600; + color: #6B7280; + } + .message-box { + background-color: #FEF3C7; + border-left: 4px solid #F59E0B; + padding: 15px; + margin: 20px 0; + border-radius: 4px; + } + .document-list { + margin: 20px 0; + } + .document-item { + border: 1px solid #E5E7EB; + border-radius: 6px; + padding: 15px; + margin-bottom: 10px; + background-color: #FAFAFA; + } + .document-name { + font-weight: 600; + color: #111827; + margin-bottom: 8px; + } + .document-meta { + display: flex; + gap: 15px; + font-size: 14px; + color: #6B7280; + } + .badge { + display: inline-block; + padding: 2px 8px; + border-radius: 4px; + font-size: 12px; + font-weight: 600; + } + .badge-buyer { + background-color: #DBEAFE; + color: #1E40AF; + } + .badge-vendor { + background-color: #E0E7FF; + color: #3730A3; + } + .status { + display: inline-block; + padding: 2px 8px; + border-radius: 4px; + font-size: 12px; + font-weight: 600; + } + .status.approved { + background-color: #D1FAE5; + color: #065F46; + } + .status.rejected { + background-color: #FEE2E2; + color: #991B1B; + } + .status.pending { + background-color: #FEF3C7; + color: #92400E; + } + .status.reviewing { + background-color: #DBEAFE; + color: #1E40AF; + } + .status.unreviewed { + background-color: #F3F4F6; + color: #6B7280; + } + .footer { + margin-top: 30px; + padding-top: 20px; + border-top: 1px solid #E5E7EB; + font-size: 14px; + color: #6B7280; + text-align: center; + } + .footer a { + color: #4F46E5; + text-decoration: none; + } + .notice { + background-color: #EFF6FF; + border: 1px solid #BFDBFE; + border-radius: 6px; + padding: 12px; + margin-top: 20px; + font-size: 14px; + color: #1E40AF; + } + </style> +</head> +<body> + <div class="container"> + <div class="header"> + <div class="logo">TBE Platform</div> + <div class="title">{{t "email.document_share_title"}}</div> + </div> + + <p>안녕하세요,</p> + + <p><strong>{{senderName}}</strong> ({{senderEmail}})님이 TBE 세션 문서를 공유했습니다.</p> + + <div class="session-info"> + <div class="session-info-item"> + <span class="session-info-label">세션 제목:</span> + <span>{{sessionTitle}}</span> + </div> + <div class="session-info-item"> + <span class="session-info-label">세션 ID:</span> + <span>#{{sessionId}}</span> + </div> + <div class="session-info-item"> + <span class="session-info-label">Buyer:</span> + <span>{{buyerName}}</span> + </div> + <div class="session-info-item"> + <span class="session-info-label">Vendor:</span> + <span>{{vendorName}}</span> + </div> + <div class="session-info-item"> + <span class="session-info-label">문서 개수:</span> + <span>{{documentCount}}개</span> + </div> + </div> + + {{#if hasComments}} + <div class="message-box"> + <strong>메시지:</strong><br> + {{comments}} + </div> + {{/if}} + + <h3 style="margin-top: 30px; margin-bottom: 15px;">첨부된 문서 목록</h3> + + <div class="document-list"> + {{#each documents}} + <div class="document-item"> + <div class="document-name">📄 {{name}}</div> + <div class="document-meta"> + <span>유형: {{type}}</span> + <span class="badge badge-{{source}}">{{source}}</span> + <span class="status {{reviewStatusClass}}">{{reviewStatus}}</span> + </div> + </div> + {{/each}} + </div> + + <div class="notice"> + <strong>📎 첨부 파일 안내:</strong><br> + 이 이메일에는 {{documentCount}}개의 문서가 첨부되어 있습니다. + 첨부 파일을 다운로드하여 확인하실 수 있습니다. + </div> + + <div class="footer"> + <p>이 이메일은 TBE Platform에서 자동으로 발송되었습니다.</p> + <p>문의사항이 있으시면 <a href="mailto:support@tbe-platform.com">support@tbe-platform.com</a>으로 연락주세요.</p> + <p>© {{year}} TBE Platform. All rights reserved.</p> + </div> + </div> +</body> +</html>
\ No newline at end of file diff --git a/lib/rfq-last/compare-action.ts b/lib/rfq-last/compare-action.ts index 5d210631..2be594e9 100644 --- a/lib/rfq-last/compare-action.ts +++ b/lib/rfq-last/compare-action.ts @@ -1,7 +1,7 @@ "use server"; import db from "@/db/db"; -import { eq, and, inArray } from "drizzle-orm"; +import { eq, and, inArray,ne } from "drizzle-orm"; import { rfqsLast, rfqLastDetails, @@ -10,8 +10,13 @@ import { rfqLastVendorQuotationItems, vendors, paymentTerms, - incoterms, + incoterms,vendorSelections } from "@/db/schema"; +import { revalidatePath } from "next/cache"; +import { getServerSession } from "next-auth/next" +import { authOptions } from "@/app/api/auth/[...nextauth]/route" + +// ===== 타입 정의 ===== export interface ComparisonData { rfqInfo: { @@ -55,7 +60,7 @@ export interface VendorComparison { totalAmount: number; currency: string; rank?: number; - priceVariance?: number; // 평균 대비 차이 % + priceVariance?: number; // 구매자 제시 조건 buyerConditions: { @@ -69,8 +74,6 @@ export interface VendorComparison { taxCode?: string; placeOfShipping?: string; placeOfDestination?: string; - - // 추가 조건 firstYn: boolean; firstDescription?: string; sparepartYn: boolean; @@ -90,8 +93,6 @@ export interface VendorComparison { taxCode?: string; placeOfShipping?: string; placeOfDestination?: string; - - // 추가 조건 응답 firstAcceptance?: "수용" | "부분수용" | "거부"; firstDescription?: string; sparepartAcceptance?: "수용" | "부분수용" | "거부"; @@ -104,12 +105,28 @@ export interface VendorComparison { conditionDifferences: { hasDifferences: boolean; differences: string[]; - criticalDifferences: string[]; // 중요한 차이점 + criticalDifferences: string[]; }; // 비고 generalRemark?: string; technicalProposal?: string; + + // 선정 관련 정보 + isSelected?: boolean; + selectionDate?: Date | null; + selectionReason?: string; + selectedBy?: number; + selectedByName?: string; + selectionApprovalStatus?: "대기" | "승인" | "반려" | null; + selectionApprovedBy?: number; + selectionApprovedAt?: Date | null; + selectionApprovalComment?: string; + + // 계약 관련 정보 추가 + contractStatus?: string; + contractNo?: string; + contractCreatedAt?: Date | null; } export interface PrItemComparison { @@ -143,10 +160,12 @@ export interface PrItemComparison { lowestPrice: number; highestPrice: number; averagePrice: number; - priceVariance: number; // 표준편차 + priceVariance: number; }; } +// ===== 메인 조회 함수 ===== + export async function getComparisonData( rfqId: number, vendorIds: number[] @@ -159,8 +178,6 @@ export async function getComparisonData( rfqCode: rfqsLast.rfqCode, rfqTitle: rfqsLast.rfqTitle, rfqType: rfqsLast.rfqType, - // projectCode: rfqsLast.projectCode, - // projectName: rfqsLast.projectName, dueDate: rfqsLast.dueDate, packageNo: rfqsLast.packageNo, packageName: rfqsLast.packageName, @@ -171,7 +188,7 @@ export async function getComparisonData( if (!rfqData[0]) return null; - // 2. 벤더별 정보 및 응답 조회 + // 2. 벤더별 정보, 응답, 선정 정보 조회 const vendorData = await db .select({ // 벤더 정보 @@ -197,6 +214,21 @@ export async function getComparisonData( buyerSparepartDescription: rfqLastDetails.sparepartDescription, buyerMaterialPriceRelatedYn: rfqLastDetails.materialPriceRelatedYn, + // 선정 관련 정보 + isSelected: rfqLastDetails.isSelected, + selectionDate: rfqLastDetails.selectionDate, + selectionReason: rfqLastDetails.selectionReason, + selectedBy: rfqLastDetails.selectedBy, + selectionApprovalStatus: rfqLastDetails.selectionApprovalStatus, + selectionApprovedBy: rfqLastDetails.selectionApprovedBy, + selectionApprovedAt: rfqLastDetails.selectionApprovedAt, + selectionApprovalComment: rfqLastDetails.selectionApprovalComment, + + // 계약 관련 정보 + contractStatus: rfqLastDetails.contractStatus, + contractNo: rfqLastDetails.contractNo, + contractCreatedAt: rfqLastDetails.contractCreatedAt, + // 벤더 응답 responseId: rfqLastVendorResponses.id, participationStatus: rfqLastVendorResponses.participationStatus, @@ -247,7 +279,19 @@ export async function getComparisonData( ) .where(inArray(vendors.id, vendorIds)); - // 3. Payment Terms와 Incoterms 설명 조회 + // 3. 선정자 이름 조회 (선정된 업체가 있는 경우) + const selectedVendor = vendorData.find(v => v.isSelected); + let selectedByName = ""; + if (selectedVendor?.selectedBy) { + const [user] = await db + .select({ name: users.name }) + .from(users) + .where(eq(users.id, selectedVendor.selectedBy)) + .limit(1); + selectedByName = user?.name || ""; + } + + // 4. Payment Terms와 Incoterms 설명 조회 const paymentTermsData = await db .select({ code: paymentTerms.code, @@ -269,7 +313,7 @@ export async function getComparisonData( incotermsData.map(ic => [ic.code, ic.description]) ); - // 4. PR Items 조회 + // 5. PR Items 조회 const prItems = await db .select({ id: rfqPrItems.id, @@ -284,7 +328,7 @@ export async function getComparisonData( .from(rfqPrItems) .where(eq(rfqPrItems.rfqsLastId, rfqId)); - // 5. 벤더별 견적 아이템 조회 + // 6. 벤더별 견적 아이템 조회 const quotationItems = await db .select({ vendorResponseId: rfqLastVendorQuotationItems.vendorResponseId, @@ -309,7 +353,7 @@ export async function getComparisonData( ) ); - // 6. 데이터 가공 및 분석 + // 7. 데이터 가공 및 분석 const validAmounts = vendorData .map(v => v.totalAmount) .filter(a => a != null && a > 0); @@ -318,7 +362,7 @@ export async function getComparisonData( const maxAmount = Math.max(...validAmounts); const avgAmount = validAmounts.reduce((a, b) => a + b, 0) / validAmounts.length; - // 벤더별 비교 데이터 구성 + // 8. 벤더별 비교 데이터 구성 const vendorComparisons: VendorComparison[] = vendorData.map((v, index) => { const differences: string[] = []; const criticalDifferences: string[] = []; @@ -413,16 +457,32 @@ export async function getComparisonData( generalRemark: v.generalRemark, technicalProposal: v.technicalProposal, + + // 선정 관련 정보 + isSelected: v.isSelected || false, + selectionDate: v.selectionDate, + selectionReason: v.selectionReason, + selectedBy: v.selectedBy, + selectedByName: v.isSelected ? selectedByName : undefined, + selectionApprovalStatus: v.selectionApprovalStatus, + selectionApprovedBy: v.selectionApprovedBy, + selectionApprovedAt: v.selectionApprovedAt, + selectionApprovalComment: v.selectionApprovalComment, + + // 계약 관련 정보 + contractStatus: v.contractStatus, + contractNo: v.contractNo, + contractCreatedAt: v.contractCreatedAt, }; }); - // 가격 순위 계산 + // 9. 가격 순위 계산 vendorComparisons.sort((a, b) => a.totalAmount - b.totalAmount); vendorComparisons.forEach((v, index) => { v.rank = index + 1; }); - // PR 아이템별 비교 데이터 구성 + // 10. PR 아이템별 비교 데이터 구성 const prItemComparisons: PrItemComparison[] = prItems.map(item => { const itemQuotes = quotationItems .filter(q => q.prItemId === item.id) @@ -477,7 +537,23 @@ export async function getComparisonData( }; }); - // 최종 데이터 구성 + console.log({ + rfqInfo: rfqData[0], + vendors: vendorComparisons, + prItems: prItemComparisons, + summary: { + lowestBidder: vendorComparisons[0]?.vendorName || "", + highestBidder: vendorComparisons[vendorComparisons.length - 1]?.vendorName || "", + priceRange: { + min: minAmount, + max: maxAmount, + average: avgAmount, + }, + currency: vendorComparisons[0]?.currency || "USD", + }, + }); + + // 11. 최종 데이터 반환 return { rfqInfo: rfqData[0], vendors: vendorComparisons, @@ -497,4 +573,428 @@ export async function getComparisonData( console.error("견적 비교 데이터 조회 실패:", error); return null; } -}
\ No newline at end of file +} + +interface SelectVendorParams { + rfqId: number; + vendorId: number; + vendorName: string; + vendorCode: string; + totalAmount: number; + currency: string; + selectionReason: string; + priceRank: number; + hasConditionDifferences: boolean; + criticalDifferences: string[]; + userId: number; // 현재 사용자 ID +} + +export async function selectVendor(params: SelectVendorParams) { + try { + // 트랜잭션 시작 + const result = await db.transaction(async (tx) => { + // 1. RFQ 상태 확인 + const [rfq] = await tx + .select() + .from(rfqsLast) + .where(eq(rfqsLast.id, params.rfqId)); + + if (!rfq) { + throw new Error("RFQ를 찾을 수 없습니다."); + } + + if (rfq.status === "최종업체선정") { + throw new Error("이미 업체가 선정된 RFQ입니다."); + } + + // 2. 기존에 선정된 업체가 있다면 선정 해제 + await tx + .update(rfqLastDetails) + .set({ + isSelected: false, + updatedAt: new Date(), + updatedBy: params.userId + }) + .where( + and( + eq(rfqLastDetails.rfqsLastId, params.rfqId), + eq(rfqLastDetails.isSelected, true) + ) + ); + + // 3. 새로운 업체 선정 + const [selection] = await tx + .update(rfqLastDetails) + .set({ + isSelected: true, + selectionDate: new Date(), + selectionReason: params.selectionReason, + selectedBy: params.userId, + totalAmount: params.totalAmount.toString(), + priceRank: params.priceRank, + updatedAt: new Date(), + updatedBy: params.userId, + }) + .where( + and( + eq(rfqLastDetails.rfqsLastId, params.rfqId), + eq(rfqLastDetails.vendorsId, params.vendorId), + eq(rfqLastDetails.isLatest, true) + ) + ) + .returning(); + + if (!selection) { + throw new Error("업체 견적 정보를 찾을 수 없습니다."); + } + + // 4. RFQ 상태 업데이트 + await tx + .update(rfqsLast) + .set({ + status: "최종업체선정", + updatedAt: new Date(), + }) + .where(eq(rfqsLast.id, params.rfqId)); + + // 5. 다른 업체들의 견적은 미선정 상태로 명시적 업데이트 + await tx + .update(rfqLastDetails) + .set({ + isSelected: false, + updatedAt: new Date(), + }) + .where( + and( + eq(rfqLastDetails.rfqsLastId, params.rfqId), + eq(rfqLastDetails.isLatest, true), + // NOT equal to selected vendor + // Drizzle에서는 ne (not equal) 연산자를 사용 + ne(rfqLastDetails.vendorsId, params.vendorId) + ) + ); + + return selection; + }); + + // 캐시 무효화 + revalidatePath(`/evcp/rfq-last/${params.rfqId}`); + revalidatePath("/evcp/rfq"); + + return { + success: true, + data: result, + redirectUrl: `/evcp/rfq-last/${params.rfqId}/selection-complete` + }; + + } catch (error) { + console.error("업체 선정 오류:", error); + return { + success: false, + error: error instanceof Error ? error.message : "업체 선정 중 오류가 발생했습니다." + }; + } +} + +// 업체 선정 취소 +export async function cancelVendorSelection(rfqId: number, cancelReason: string) { + try { + const session = await getServerSession(authOptions) + if (!session?.user) { + throw new Error("인증이 필요합니다.") + } + + const userId = Number(session.user.id) + + + await db.transaction(async (tx) => { + // 선정 정보 업데이트 (취소 사유 기록) + const [cancelled] = await tx + .update(rfqLastDetails) + .set({ + isSelected: false, + selectionDate: null, + selectionReason: null, + selectedBy: null, + cancelReason: cancelReason, + selectionApprovalStatus: null, + selectionApprovedBy: null, + selectionApprovedAt: null, + selectionApprovalComment: null, + updatedAt: new Date(), + updatedBy: userId, + }) + .where( + and( + eq(rfqLastDetails.rfqsLastId, rfqId), + eq(rfqLastDetails.isSelected, true) + ) + ) + .returning(); + + if (!cancelled) { + throw new Error("선정된 업체를 찾을 수 없습니다."); + } + + // RFQ 상태 되돌리기 + await tx + .update(rfqsLast) + .set({ + status: "견적접수", + updatedAt: new Date(), + }) + .where(eq(rfqsLast.id, rfqId)); + }); + + revalidatePath(`/evcp/rfq-last/${rfqId}`); + revalidatePath("/evcp/rfq-last"); + + return { success: true }; + } catch (error) { + console.error("업체 선정 취소 오류:", error); + return { + success: false, + error: error instanceof Error ? error.message : "업체 선정 취소 중 오류가 발생했습니다." + }; + } +} +// 선정된 업체 정보 조회 +export async function getSelectedVendor(rfqId: number) { + try { + const [selected] = await db + .select({ + detail: rfqLastDetails, + vendor: vendors, + }) + .from(rfqLastDetails) + .leftJoin(vendors, eq(rfqLastDetails.vendorsId, vendors.id)) + .where( + and( + eq(rfqLastDetails.rfqsLastId, rfqId), + eq(rfqLastDetails.isSelected, true), + eq(rfqLastDetails.isLatest, true) + ) + ) + .limit(1); + + return { + success: true, + data: selected + }; + } catch (error) { + console.error("선정 업체 조회 오류:", error); + return { + success: false, + error: error instanceof Error ? error.message : "선정 업체 조회 중 오류가 발생했습니다." + }; + } +} + +// 선정 승인 요청 +export async function requestSelectionApproval( + rfqId: number, + vendorId: number, + userId: number +) { + try { + const [updated] = await db + .update(rfqLastDetails) + .set({ + selectionApprovalStatus: "대기", + updatedAt: new Date(), + updatedBy: userId, + }) + .where( + and( + eq(rfqLastDetails.rfqsLastId, rfqId), + eq(rfqLastDetails.vendorsId, vendorId), + eq(rfqLastDetails.isSelected, true), + eq(rfqLastDetails.isLatest, true) + ) + ) + .returning(); + + if (!updated) { + throw new Error("선정된 업체를 찾을 수 없습니다."); + } + + revalidatePath(`/evcp/rfq-last/${rfqId}`); + + return { + success: true, + data: updated + }; + } catch (error) { + console.error("선정 승인 요청 오류:", error); + return { + success: false, + error: error instanceof Error ? error.message : "선정 승인 요청 중 오류가 발생했습니다." + }; + } +} + +// 선정 승인/반려 처리 +export async function processSelectionApproval( + rfqId: number, + vendorId: number, + action: "승인" | "반려", + comment: string, + approverId: number +) { + try { + await db.transaction(async (tx) => { + // 선정 승인 정보 업데이트 + const [updated] = await tx + .update(rfqLastDetails) + .set({ + selectionApprovalStatus: action, + selectionApprovedBy: approverId, + selectionApprovedAt: new Date(), + selectionApprovalComment: comment, + updatedAt: new Date(), + updatedBy: approverId, + }) + .where( + and( + eq(rfqLastDetails.rfqsLastId, rfqId), + eq(rfqLastDetails.vendorsId, vendorId), + eq(rfqLastDetails.isSelected, true), + eq(rfqLastDetails.isLatest, true) + ) + ) + .returning(); + + if (!updated) { + throw new Error("선정된 업체를 찾을 수 없습니다."); + } + + // 승인된 경우 RFQ 상태 업데이트 + if (action === "승인") { + await tx + .update(rfqsLast) + .set({ + status: "계약 진행중", + updatedAt: new Date(), + }) + .where(eq(rfqsLast.id, rfqId)); + } + }); + + revalidatePath(`/evcp/rfq-last/${rfqId}`); + revalidatePath("/evcp/rfq"); + + return { + success: true, + message: action === "승인" + ? "업체 선정이 승인되었습니다." + : "업체 선정이 반려되었습니다." + }; + } catch (error) { + console.error("선정 승인 처리 오류:", error); + return { + success: false, + error: error instanceof Error ? error.message : "선정 승인 처리 중 오류가 발생했습니다." + }; + } +} + +// 가격 순위 업데이트 (견적 제출 후 자동 실행) +export async function updatePriceRanks(rfqId: number) { + try { + // 해당 RFQ의 모든 최신 견적 조회 + const quotes = await db + .select({ + id: rfqLastDetails.id, + vendorsId: rfqLastDetails.vendorsId, + totalAmount: rfqLastDetails.totalAmount, + }) + .from(rfqLastDetails) + .where( + and( + eq(rfqLastDetails.rfqsLastId, rfqId), + eq(rfqLastDetails.isLatest, true) + ) + ) + .orderBy(asc(rfqLastDetails.totalAmount)); + + // 순위 업데이트 + await db.transaction(async (tx) => { + for (let i = 0; i < quotes.length; i++) { + await tx + .update(rfqLastDetails) + .set({ + priceRank: i + 1, + updatedAt: new Date() + }) + .where(eq(rfqLastDetails.id, quotes[i].id)); + } + }); + + return { + success: true, + message: "가격 순위가 업데이트되었습니다." + }; + } catch (error) { + console.error("가격 순위 업데이트 오류:", error); + return { + success: false, + error: error instanceof Error ? error.message : "가격 순위 업데이트 중 오류가 발생했습니다." + }; + } +} + +// RFQ의 모든 견적 상태 조회 +export async function getRfqQuotationStatus(rfqId: number) { + try { + const quotations = await db + .select({ + id: rfqLastDetails.id, + vendorId: rfqLastDetails.vendorsId, + vendorName: vendors.name, + vendorCode: vendors.code, + totalAmount: rfqLastDetails.totalAmount, + currency: rfqLastDetails.currency, + priceRank: rfqLastDetails.priceRank, + isSelected: rfqLastDetails.isSelected, + selectionDate: rfqLastDetails.selectionDate, + selectionReason: rfqLastDetails.selectionReason, + selectionApprovalStatus: rfqLastDetails.selectionApprovalStatus, + emailStatus: rfqLastDetails.emailStatus, + lastEmailSentAt: rfqLastDetails.lastEmailSentAt, + }) + .from(rfqLastDetails) + .leftJoin(vendors, eq(rfqLastDetails.vendorsId, vendors.id)) + .where( + and( + eq(rfqLastDetails.rfqsLastId, rfqId), + eq(rfqLastDetails.isLatest, true) + ) + ) + .orderBy(asc(rfqLastDetails.priceRank)); + + const selectedVendor = quotations.find(q => q.isSelected); + const totalQuotations = quotations.length; + const respondedQuotations = quotations.filter(q => q.totalAmount).length; + + return { + success: true, + data: { + quotations, + summary: { + total: totalQuotations, + responded: respondedQuotations, + pending: totalQuotations - respondedQuotations, + selected: selectedVendor ? 1 : 0, + selectedVendor: selectedVendor || null, + } + } + }; + } catch (error) { + console.error("RFQ 견적 상태 조회 오류:", error); + return { + success: false, + error: error instanceof Error ? error.message : "견적 상태 조회 중 오류가 발생했습니다." + }; + } +} + diff --git a/lib/rfq-last/contract-actions.ts b/lib/rfq-last/contract-actions.ts new file mode 100644 index 00000000..1144cf4f --- /dev/null +++ b/lib/rfq-last/contract-actions.ts @@ -0,0 +1,297 @@ +"use server"; + +import db from "@/db/db"; +import { rfqsLast, rfqLastDetails } from "@/db/schema"; +import { eq, and } from "drizzle-orm"; +import { revalidatePath } from "next/cache"; + +// ===== PO (SAP) 생성 ===== +interface CreatePOParams { + rfqId: number; + vendorId: number; + vendorName: string; + totalAmount: number; + currency: string; + selectionReason?: string; +} + +export async function createPO(params: CreatePOParams) { + try { + const userId = 1; // TODO: 실제 사용자 ID 가져오기 + + // 1. 선정된 업체 확인 + const [selectedVendor] = await db + .select() + .from(rfqLastDetails) + .where( + and( + eq(rfqLastDetails.rfqsLastId, params.rfqId), + eq(rfqLastDetails.vendorsId, params.vendorId), + eq(rfqLastDetails.isSelected, true), + eq(rfqLastDetails.isLatest, true) + ) + ); + + if (!selectedVendor) { + throw new Error("선정된 업체 정보를 찾을 수 없습니다."); + } + + // 2. SAP 연동 로직 (TODO: 실제 구현 필요) + // - SAP API 호출 + // - PO 번호 생성 + // - 아이템 정보 전송 + // - 결재 라인 설정 + + // 3. 계약 상태 업데이트 + await db.transaction(async (tx) => { + // rfqLastDetails에 계약 정보 업데이트 + await tx + .update(rfqLastDetails) + .set({ + contractStatus: "진행중", + contractCreatedAt: new Date(), + contractNo: `PO-${Date.now()}`, // TODO: 실제 PO 번호로 변경 + updatedAt: new Date(), + updatedBy: userId, + }) + .where( + and( + eq(rfqLastDetails.rfqsLastId, params.rfqId), + eq(rfqLastDetails.vendorsId, params.vendorId), + eq(rfqLastDetails.isSelected, true) + ) + ); + + // RFQ 상태 업데이트 + await tx + .update(rfqsLast) + .set({ + status: "PO 생성 완료", + updatedAt: new Date(), + }) + .where(eq(rfqsLast.id, params.rfqId)); + }); + + revalidatePath(`/rfq/${params.rfqId}`); + revalidatePath("/rfq"); + + return { + success: true, + message: "PO가 성공적으로 생성되었습니다.", + poNumber: `PO-${Date.now()}`, // TODO: 실제 PO 번호 반환 + }; + } catch (error) { + console.error("PO 생성 오류:", error); + return { + success: false, + error: error instanceof Error ? error.message : "PO 생성 중 오류가 발생했습니다." + }; + } +} + +// ===== 일반계약 생성 ===== +interface CreateGeneralContractParams { + rfqId: number; + vendorId: number; + vendorName: string; + totalAmount: number; + currency: string; + contractType?: string; + contractStartDate?: Date; + contractEndDate?: Date; + contractTerms?: string; +} + +export async function createGeneralContract(params: CreateGeneralContractParams) { + try { + const userId = 1; // TODO: 실제 사용자 ID 가져오기 + + // 1. 선정된 업체 확인 + const [selectedVendor] = await db + .select() + .from(rfqLastDetails) + .where( + and( + eq(rfqLastDetails.rfqsLastId, params.rfqId), + eq(rfqLastDetails.vendorsId, params.vendorId), + eq(rfqLastDetails.isSelected, true), + eq(rfqLastDetails.isLatest, true) + ) + ); + + if (!selectedVendor) { + throw new Error("선정된 업체 정보를 찾을 수 없습니다."); + } + + // 2. 계약 생성 로직 (TODO: 실제 구현 필요) + // - 계약서 템플릿 선택 + // - 계약 조건 설정 + // - 계약서 문서 생성 + // - 전자서명 프로세스 시작 + + // 3. 계약 상태 업데이트 + await db.transaction(async (tx) => { + // rfqLastDetails에 계약 정보 업데이트 + await tx + .update(rfqLastDetails) + .set({ + contractStatus: "진행중", + contractCreatedAt: new Date(), + contractNo: `CONTRACT-${Date.now()}`, // TODO: 실제 계약번호로 변경 + updatedAt: new Date(), + updatedBy: userId, + }) + .where( + and( + eq(rfqLastDetails.rfqsLastId, params.rfqId), + eq(rfqLastDetails.vendorsId, params.vendorId), + eq(rfqLastDetails.isSelected, true) + ) + ); + + // RFQ 상태 업데이트 + await tx + .update(rfqsLast) + .set({ + status: "일반계약 진행중", + updatedAt: new Date(), + }) + .where(eq(rfqsLast.id, params.rfqId)); + }); + + revalidatePath(`/rfq/${params.rfqId}`); + revalidatePath("/rfq"); + + return { + success: true, + message: "일반계약이 성공적으로 생성되었습니다.", + contractNumber: `CONTRACT-${Date.now()}`, // TODO: 실제 계약번호 반환 + }; + } catch (error) { + console.error("일반계약 생성 오류:", error); + return { + success: false, + error: error instanceof Error ? error.message : "일반계약 생성 중 오류가 발생했습니다." + }; + } +} + +// ===== 입찰 생성 ===== +interface CreateBiddingParams { + rfqId: number; + vendorId: number; + vendorName: string; + totalAmount: number; + currency: string; + biddingType?: string; // 공개입찰, 제한입찰 등 + biddingStartDate?: Date; + biddingEndDate?: Date; + biddingRequirements?: string; +} + +export async function createBidding(params: CreateBiddingParams) { + try { + const userId = 1; // TODO: 실제 사용자 ID 가져오기 + + // 1. 선정된 업체 확인 + const [selectedVendor] = await db + .select() + .from(rfqLastDetails) + .where( + and( + eq(rfqLastDetails.rfqsLastId, params.rfqId), + eq(rfqLastDetails.vendorsId, params.vendorId), + eq(rfqLastDetails.isSelected, true), + eq(rfqLastDetails.isLatest, true) + ) + ); + + if (!selectedVendor) { + throw new Error("선정된 업체 정보를 찾을 수 없습니다."); + } + + // 2. 입찰 생성 로직 (TODO: 실제 구현 필요) + // - 입찰 공고 생성 + // - 입찰 참가자격 설정 + // - 입찰 일정 등록 + // - 평가 기준 설정 + // - 입찰 시스템 등록 + + // 3. 입찰 상태 업데이트 + await db.transaction(async (tx) => { + // rfqLastDetails에 입찰 정보 업데이트 + await tx + .update(rfqLastDetails) + .set({ + contractStatus: "입찰진행중", + contractCreatedAt: new Date(), + contractNo: `BID-${Date.now()}`, // TODO: 실제 입찰번호로 변경 + updatedAt: new Date(), + updatedBy: userId, + }) + .where( + and( + eq(rfqLastDetails.rfqsLastId, params.rfqId), + eq(rfqLastDetails.vendorsId, params.vendorId), + eq(rfqLastDetails.isSelected, true) + ) + ); + + // RFQ 상태 업데이트 + await tx + .update(rfqsLast) + .set({ + status: "입찰 진행중", + updatedAt: new Date(), + }) + .where(eq(rfqsLast.id, params.rfqId)); + }); + + revalidatePath(`/rfq/${params.rfqId}`); + revalidatePath("/rfq"); + + return { + success: true, + message: "입찰이 성공적으로 생성되었습니다.", + biddingNumber: `BID-${Date.now()}`, // TODO: 실제 입찰번호 반환 + }; + } catch (error) { + console.error("입찰 생성 오류:", error); + return { + success: false, + error: error instanceof Error ? error.message : "입찰 생성 중 오류가 발생했습니다." + }; + } +} + +// ===== 계약 타입 확인 ===== +export async function checkContractStatus(rfqId: number) { + try { + const [detail] = await db + .select({ + contractStatus: rfqLastDetails.contractStatus, + contractNo: rfqLastDetails.contractNo, + contractCreatedAt: rfqLastDetails.contractCreatedAt, + }) + .from(rfqLastDetails) + .where( + and( + eq(rfqLastDetails.rfqsLastId, rfqId), + eq(rfqLastDetails.isSelected, true), + eq(rfqLastDetails.isLatest, true) + ) + ); + + return { + success: true, + data: detail, + hasContract: !!detail?.contractNo, + }; + } catch (error) { + console.error("계약 상태 확인 오류:", error); + return { + success: false, + error: error instanceof Error ? error.message : "계약 상태 확인 중 오류가 발생했습니다." + }; + } +}
\ No newline at end of file diff --git a/lib/rfq-last/quotation-compare-view.tsx b/lib/rfq-last/quotation-compare-view.tsx index 0e15a7bf..491a1962 100644 --- a/lib/rfq-last/quotation-compare-view.tsx +++ b/lib/rfq-last/quotation-compare-view.tsx @@ -5,6 +5,7 @@ import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; +import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"; import { Trophy, TrendingUp, @@ -22,6 +23,11 @@ import { FileText, Truck, AlertTriangle, + Award, + UserCheck, + X, + RefreshCw, + Clock, } from "lucide-react"; import { cn } from "@/lib/utils"; import { format } from "date-fns"; @@ -37,7 +43,9 @@ import { CollapsibleContent, CollapsibleTrigger, } from "@/components/ui/collapsible"; -import type { ComparisonData, VendorComparison, PrItemComparison } from "../actions"; +import { ComparisonData, selectVendor, cancelVendorSelection } from "./compare-action"; +import { createPO, createGeneralContract, createBidding } from "./contract-actions"; +import { toast } from "sonner"; interface QuotationCompareViewProps { data: ComparisonData; @@ -45,7 +53,96 @@ interface QuotationCompareViewProps { export function QuotationCompareView({ data }: QuotationCompareViewProps) { const [expandedItems, setExpandedItems] = React.useState<Set<number>>(new Set()); - const [selectedMetric, setSelectedMetric] = React.useState<"price" | "delivery" | "compliance">("price"); + const [selectedVendorId, setSelectedVendorId] = React.useState<string>(""); + const [showSelectionDialog, setShowSelectionDialog] = React.useState(false); + const [showCancelDialog, setShowCancelDialog] = React.useState(false); + const [showContractDialog, setShowContractDialog] = React.useState(false); + const [selectedContractType, setSelectedContractType] = React.useState<"PO" | "CONTRACT" | "BIDDING" | "">(""); + const [selectionReason, setSelectionReason] = React.useState(""); + const [cancelReason, setCancelReason] = React.useState(""); + const [isSubmitting, setIsSubmitting] = React.useState(false); + + // 선정된 업체 정보 확인 + const selectedVendor = data.vendors.find(v => v.isSelected); + const hasSelection = !!selectedVendor; + const isSelectionApproved = selectedVendor?.selectionApprovalStatus === "승인"; + const isPendingApproval = selectedVendor?.selectionApprovalStatus === "대기"; + const hasContract = selectedVendor?.contractStatus ? true : false; + + // 계약 진행 처리 + const handleContractCreation = async () => { + if (!selectedContractType) { + toast.error("계약 유형을 선택해주세요."); + return; + } + + if (!selectedVendor) { + toast.error("선정된 업체가 없습니다."); + return; + } + + setIsSubmitting(true); + try { + let result; + + switch (selectedContractType) { + case "PO": + result = await createPO({ + rfqId: data.rfqInfo.id, + vendorId: selectedVendor.vendorId, + vendorName: selectedVendor.vendorName, + totalAmount: selectedVendor.totalAmount, + currency: selectedVendor.currency, + selectionReason: selectedVendor.selectionReason, + }); + break; + + case "CONTRACT": + result = await createGeneralContract({ + rfqId: data.rfqInfo.id, + vendorId: selectedVendor.vendorId, + vendorName: selectedVendor.vendorName, + totalAmount: selectedVendor.totalAmount, + currency: selectedVendor.currency, + }); + break; + + case "BIDDING": + result = await createBidding({ + rfqId: data.rfqInfo.id, + vendorId: selectedVendor.vendorId, + vendorName: selectedVendor.vendorName, + totalAmount: selectedVendor.totalAmount, + currency: selectedVendor.currency, + }); + break; + + default: + throw new Error("올바른 계약 유형이 아닙니다."); + } + + if (result.success) { + toast.success(result.message || "계약 프로세스가 시작되었습니다."); + setShowContractDialog(false); + setSelectedContractType(""); + window.location.reload(); + } else { + throw new Error(result.error || "계약 진행 중 오류가 발생했습니다."); + } + } catch (error) { + console.error("계약 생성 오류:", error); + toast.error(error instanceof Error ? error.message : "계약 진행 중 오류가 발생했습니다."); + } finally { + setIsSubmitting(false); + } + }; + + // 컴포넌트 마운트 시 선정된 업체가 있으면 자동 선택 + React.useEffect(() => { + if (selectedVendor) { + setSelectedVendorId(selectedVendor.vendorId.toString()); + } + }, [selectedVendor]); // 아이템 확장/축소 토글 const toggleItemExpansion = (itemId: number) => { @@ -81,17 +178,8 @@ export function QuotationCompareView({ data }: QuotationCompareViewProps) { return "text-gray-600"; }; - // 조건 일치 여부 아이콘 - const getComplianceIcon = (matches: boolean) => { - return matches ? ( - <CheckCircle className="h-4 w-4 text-green-500" /> - ) : ( - <XCircle className="h-4 w-4 text-red-500" /> - ); - }; - // 금액 포맷 - const formatAmount = (amount: number, currency: string = "USD") => { + const formatAmount = (amount: number, currency: string = "KRW") => { return new Intl.NumberFormat("ko-KR", { style: "currency", currency: currency, @@ -100,8 +188,227 @@ export function QuotationCompareView({ data }: QuotationCompareViewProps) { }).format(amount); }; + // 업체 선정 처리 + const handleVendorSelection = async () => { + if (!selectedVendorId) { + toast.error("선정할 업체를 선택해주세요."); + return; + } + + if (!selectionReason.trim()) { + toast.error("선정 사유를 입력해주세요."); + return; + } + + setIsSubmitting(true); + try { + const vendor = data.vendors.find(v => v.vendorId === parseInt(selectedVendorId)); + if (!vendor) { + throw new Error("선택한 업체를 찾을 수 없습니다."); + } + + const result = await selectVendor({ + rfqId: data.rfqInfo.id, + vendorId: vendor.vendorId, + vendorName: vendor.vendorName, + vendorCode: vendor.vendorCode, + totalAmount: vendor.totalAmount, + currency: vendor.currency, + selectionReason: selectionReason, + priceRank: vendor.rank || 0, + hasConditionDifferences: vendor.conditionDifferences.hasDifferences, + criticalDifferences: vendor.conditionDifferences.criticalDifferences, + }); + + if (result.success) { + toast.success("업체가 성공적으로 선정되었습니다."); + setShowSelectionDialog(false); + setSelectionReason(""); + window.location.reload(); // 페이지 새로고침으로 선정 상태 반영 + } else { + throw new Error(result.error || "업체 선정 중 오류가 발생했습니다."); + } + } catch (error) { + console.error("업체 선정 오류:", error); + toast.error(error instanceof Error ? error.message : "업체 선정 중 오류가 발생했습니다."); + } finally { + setIsSubmitting(false); + } + }; + + // 업체 선정 취소 처리 + const handleCancelSelection = async () => { + if (!cancelReason.trim()) { + toast.error("취소 사유를 입력해주세요."); + return; + } + + setIsSubmitting(true); + try { + // 파라미터를 올바르게 전달 + const result = await cancelVendorSelection(Number(data.rfqInfo.id),cancelReason); + + if (result.success) { + toast.success("업체 선정이 취소되었습니다."); + setShowCancelDialog(false); + setCancelReason(""); + window.location.reload(); + } else { + throw new Error(result.error || "선정 취소 중 오류가 발생했습니다."); + } + } catch (error) { + console.error("선정 취소 오류:", error); + toast.error(error instanceof Error ? error.message : "선정 취소 중 오류가 발생했습니다."); + } finally { + setIsSubmitting(false); + } + }; + return ( <div className="space-y-6"> + {/* 상단 액션 바 */} + <div className="flex justify-between items-center"> + <h2 className="text-2xl font-bold">견적 비교 분석</h2> + <div className="flex gap-2"> + {hasSelection ? ( + <> + {!isSelectionApproved && ( + <Button + variant="destructive" + onClick={() => setShowCancelDialog(true)} + className="gap-2" + > + <X className="h-4 w-4" /> + 선정 취소 + </Button> + )} + <Button + variant="outline" + onClick={() => { + setSelectedVendorId(""); + setShowSelectionDialog(true); + }} + disabled={isSelectionApproved} + className="gap-2" + > + <RefreshCw className="h-4 w-4" /> + 재선정 + </Button> + </> + ) : ( + <Button + onClick={() => setShowSelectionDialog(true)} + disabled={!selectedVendorId} + className="gap-2" + > + <Award className="h-4 w-4" /> + 업체 선정 + </Button> + )} + </div> + </div> + + {/* 선정 상태 알림 */} + {hasSelection && ( + <Alert className={cn( + "border-2", + hasContract + ? "border-purple-500 bg-purple-50" + : isSelectionApproved + ? "border-green-500 bg-green-50" + : isPendingApproval + ? "border-yellow-500 bg-yellow-50" + : "border-blue-500 bg-blue-50" + )}> + <div className="flex items-start justify-between"> + <div className="flex gap-3"> + {hasContract ? ( + <FileText className="h-5 w-5 text-purple-600 mt-0.5" /> + ) : isSelectionApproved ? ( + <CheckCircle className="h-5 w-5 text-green-600 mt-0.5" /> + ) : isPendingApproval ? ( + <Clock className="h-5 w-5 text-yellow-600 mt-0.5" /> + ) : ( + <Award className="h-5 w-5 text-blue-600 mt-0.5" /> + )} + <div className="space-y-2"> + <AlertTitle className="text-lg"> + {hasContract + ? "계약 진행중" + : isSelectionApproved + ? "업체 선정 승인 완료" + : isPendingApproval + ? "업체 선정 승인 대기중" + : "업체 선정 완료"} + </AlertTitle> + <AlertDescription className="space-y-1"> + <p className="font-semibold">선정 업체: {selectedVendor.vendorName} ({selectedVendor.vendorCode})</p> + <p>선정 금액: {formatAmount(selectedVendor.totalAmount, selectedVendor.currency)}</p> + <p>선정일: {selectedVendor.selectionDate ? format(new Date(selectedVendor.selectionDate), "yyyy년 MM월 dd일", { locale: ko }) : "-"}</p> + <p>선정 사유: {selectedVendor.selectionReason || "-"}</p> + {selectedVendor.contractNo && ( + <> + <div className="border-t pt-1 mt-2"> + <p className="font-semibold">계약 정보</p> + <p>계약 번호: {selectedVendor.contractNo}</p> + <p>계약 상태: {selectedVendor.contractStatus}</p> + {selectedVendor.contractCreatedAt && ( + <p>계약 생성일: {format(new Date(selectedVendor.contractCreatedAt), "yyyy년 MM월 dd일", { locale: ko })}</p> + )} + </div> + </> + )} + {selectedVendor.selectedByName && ( + <p className="text-sm text-muted-foreground">선정자: {selectedVendor.selectedByName}</p> + )} + </AlertDescription> + </div> + </div> + {/* 계약 진행 버튼들을 알림 카드 안에도 추가 (선택사항) */} + {!hasContract && !isPendingApproval && ( + <div className="flex flex-col gap-2"> + <Button + size="sm" + variant="default" + onClick={() => { + setSelectedContractType("PO"); + setShowContractDialog(true); + }} + className="gap-1 bg-green-600 hover:bg-green-700 text-xs" + > + <FileText className="h-3 w-3" /> + PO 생성 + </Button> + <Button + size="sm" + variant="default" + onClick={() => { + setSelectedContractType("CONTRACT"); + setShowContractDialog(true); + }} + className="gap-1 bg-blue-600 hover:bg-blue-700 text-xs" + > + <FileText className="h-3 w-3" /> + 일반계약 + </Button> + <Button + size="sm" + variant="default" + onClick={() => { + setSelectedContractType("BIDDING"); + setShowContractDialog(true); + }} + className="gap-1 bg-purple-600 hover:bg-purple-700 text-xs" + > + <Globe className="h-3 w-3" /> + 입찰 + </Button> + </div> + )} + </div> + </Alert> + )} + {/* 요약 카드 */} <div className="grid grid-cols-1 md:grid-cols-4 gap-4"> {/* 최저가 벤더 */} @@ -120,23 +427,40 @@ export function QuotationCompareView({ data }: QuotationCompareViewProps) { </CardContent> </Card> - {/* 평균 가격 */} - <Card> - <CardHeader className="pb-3"> - <CardTitle className="text-sm font-medium flex items-center gap-2"> - <DollarSign className="h-4 w-4" /> - 평균 가격 - </CardTitle> - </CardHeader> - <CardContent> - <p className="text-lg font-bold"> - {formatAmount(data.summary.priceRange.average, data.summary.currency)} - </p> - <p className="text-sm text-muted-foreground"> - {data.vendors.length}개 업체 평균 - </p> - </CardContent> - </Card> + {/* 선정 업체 또는 평균 가격 */} + {hasSelection ? ( + <Card className="border-2 border-blue-200 bg-blue-50"> + <CardHeader className="pb-3"> + <CardTitle className="text-sm font-medium flex items-center gap-2"> + <UserCheck className="h-4 w-4 text-blue-600" /> + 선정 업체 + </CardTitle> + </CardHeader> + <CardContent> + <p className="text-lg font-bold">{selectedVendor.vendorName}</p> + <p className="text-sm text-blue-600"> + {formatAmount(selectedVendor.totalAmount, selectedVendor.currency)} + </p> + </CardContent> + </Card> + ) : ( + <Card> + <CardHeader className="pb-3"> + <CardTitle className="text-sm font-medium flex items-center gap-2"> + <DollarSign className="h-4 w-4" /> + 평균 가격 + </CardTitle> + </CardHeader> + <CardContent> + <p className="text-lg font-bold"> + {formatAmount(data.summary.priceRange.average, data.summary.currency)} + </p> + <p className="text-sm text-muted-foreground"> + {data.vendors.length}개 업체 평균 + </p> + </CardContent> + </Card> + )} {/* 가격 범위 */} <Card> @@ -188,16 +512,40 @@ export function QuotationCompareView({ data }: QuotationCompareViewProps) { <TabsContent value="overview" className="space-y-4"> <Card> <CardHeader> - <CardTitle>가격 순위</CardTitle> + <CardTitle>가격 순위 및 업체 선정</CardTitle> </CardHeader> <CardContent> <div className="space-y-4"> {data.vendors.map((vendor) => ( <div key={vendor.vendorId} - className="flex items-center justify-between p-4 border rounded-lg hover:bg-gray-50 transition-colors" + className={cn( + "flex items-center justify-between p-4 border rounded-lg transition-colors", + vendor.isSelected + ? "bg-blue-100 border-blue-400 border-2" + : hasSelection + ? "opacity-60" + : selectedVendorId === vendor.vendorId.toString() + ? "bg-blue-50 border-blue-300 cursor-pointer" + : "hover:bg-gray-50 cursor-pointer" + )} + onClick={() => { + if (!hasSelection) { + setSelectedVendorId(vendor.vendorId.toString()); + } + }} > <div className="flex items-center gap-4"> + {!hasSelection && ( + <input + type="radio" + name="vendor-selection" + value={vendor.vendorId} + checked={selectedVendorId === vendor.vendorId.toString()} + onChange={(e) => setSelectedVendorId(e.target.value)} + className="h-4 w-4 text-blue-600" + /> + )} <div className={cn( "w-10 h-10 rounded-full flex items-center justify-center font-bold", @@ -207,7 +555,12 @@ export function QuotationCompareView({ data }: QuotationCompareViewProps) { {vendor.rank} </div> <div> - <p className="font-semibold">{vendor.vendorName}</p> + <p className="font-semibold flex items-center gap-2"> + {vendor.vendorName} + {vendor.isSelected && ( + <Badge className="bg-blue-600">선정</Badge> + )} + </p> <p className="text-sm text-muted-foreground"> {vendor.vendorCode} • {vendor.vendorCountry} </p> @@ -267,8 +620,14 @@ export function QuotationCompareView({ data }: QuotationCompareViewProps) { <th className="text-left p-2">항목</th> <th className="text-left p-2">구매자 제시</th> {data.vendors.map((vendor) => ( - <th key={vendor.vendorId} className="text-left p-2"> + <th key={vendor.vendorId} className={cn( + "text-left p-2", + vendor.isSelected && "bg-blue-50" + )}> {vendor.vendorName} + {vendor.isSelected && ( + <Badge className="ml-2 bg-blue-600">선정</Badge> + )} </th> ))} </tr> @@ -279,7 +638,10 @@ export function QuotationCompareView({ data }: QuotationCompareViewProps) { <td className="p-2 font-medium">통화</td> <td className="p-2">{data.vendors[0]?.buyerConditions.currency}</td> {data.vendors.map((vendor) => ( - <td key={vendor.vendorId} className="p-2"> + <td key={vendor.vendorId} className={cn( + "p-2", + vendor.isSelected && "bg-blue-50" + )}> <div className="flex items-center gap-2"> {vendor.vendorConditions.currency || vendor.buyerConditions.currency} {vendor.vendorConditions.currency !== vendor.buyerConditions.currency && ( @@ -306,7 +668,10 @@ export function QuotationCompareView({ data }: QuotationCompareViewProps) { </TooltipProvider> </td> {data.vendors.map((vendor) => ( - <td key={vendor.vendorId} className="p-2"> + <td key={vendor.vendorId} className={cn( + "p-2", + vendor.isSelected && "bg-blue-50" + )}> <div className="flex items-center gap-2"> <TooltipProvider> <Tooltip> @@ -327,134 +692,7 @@ export function QuotationCompareView({ data }: QuotationCompareViewProps) { ))} </tr> - {/* 인코텀즈 */} - <tr> - <td className="p-2 font-medium">인코텀즈</td> - <td className="p-2">{data.vendors[0]?.buyerConditions.incotermsCode}</td> - {data.vendors.map((vendor) => ( - <td key={vendor.vendorId} className="p-2"> - <div className="flex items-center gap-2"> - {vendor.vendorConditions.incotermsCode || vendor.buyerConditions.incotermsCode} - {vendor.vendorConditions.incotermsCode !== vendor.buyerConditions.incotermsCode && ( - <Badge variant="outline" className="text-xs">변경</Badge> - )} - </div> - </td> - ))} - </tr> - - {/* 납기 */} - <tr> - <td className="p-2 font-medium">납기</td> - <td className="p-2"> - {data.vendors[0]?.buyerConditions.deliveryDate - ? format(new Date(data.vendors[0].buyerConditions.deliveryDate), "yyyy-MM-dd") - : "-"} - </td> - {data.vendors.map((vendor) => { - const vendorDate = vendor.vendorConditions.deliveryDate || vendor.buyerConditions.deliveryDate; - const isDelayed = vendorDate && vendor.buyerConditions.deliveryDate && - new Date(vendorDate) > new Date(vendor.buyerConditions.deliveryDate); - - return ( - <td key={vendor.vendorId} className="p-2"> - <div className="flex items-center gap-2"> - {vendorDate ? format(new Date(vendorDate), "yyyy-MM-dd") : "-"} - {isDelayed && ( - <Badge variant="destructive" className="text-xs">지연</Badge> - )} - </div> - </td> - ); - })} - </tr> - - {/* 초도품 */} - <tr> - <td className="p-2 font-medium">초도품</td> - <td className="p-2"> - {data.vendors[0]?.buyerConditions.firstYn ? "요구" : "해당없음"} - </td> - {data.vendors.map((vendor) => ( - <td key={vendor.vendorId} className="p-2"> - {vendor.buyerConditions.firstYn && ( - <Badge - variant={ - vendor.vendorConditions.firstAcceptance === "수용" - ? "default" - : vendor.vendorConditions.firstAcceptance === "부분수용" - ? "secondary" - : vendor.vendorConditions.firstAcceptance === "거부" - ? "destructive" - : "outline" - } - > - {vendor.vendorConditions.firstAcceptance || "미응답"} - </Badge> - )} - {!vendor.buyerConditions.firstYn && "-"} - </td> - ))} - </tr> - - {/* 스페어파트 */} - <tr> - <td className="p-2 font-medium">스페어파트</td> - <td className="p-2"> - {data.vendors[0]?.buyerConditions.sparepartYn ? "요구" : "해당없음"} - </td> - {data.vendors.map((vendor) => ( - <td key={vendor.vendorId} className="p-2"> - {vendor.buyerConditions.sparepartYn && ( - <Badge - variant={ - vendor.vendorConditions.sparepartAcceptance === "수용" - ? "default" - : vendor.vendorConditions.sparepartAcceptance === "부분수용" - ? "secondary" - : vendor.vendorConditions.sparepartAcceptance === "거부" - ? "destructive" - : "outline" - } - > - {vendor.vendorConditions.sparepartAcceptance || "미응답"} - </Badge> - )} - {!vendor.buyerConditions.sparepartYn && "-"} - </td> - ))} - </tr> - - {/* 연동제 */} - <tr> - <td className="p-2 font-medium">연동제</td> - <td className="p-2"> - {data.vendors[0]?.buyerConditions.materialPriceRelatedYn ? "적용" : "미적용"} - </td> - {data.vendors.map((vendor) => ( - <td key={vendor.vendorId} className="p-2"> - <div className="flex items-center gap-2"> - {vendor.vendorConditions.materialPriceRelatedYn !== undefined - ? vendor.vendorConditions.materialPriceRelatedYn ? "적용" : "미적용" - : vendor.buyerConditions.materialPriceRelatedYn ? "적용" : "미적용"} - {vendor.vendorConditions.materialPriceRelatedReason && ( - <TooltipProvider> - <Tooltip> - <TooltipTrigger> - <Info className="h-3 w-3" /> - </TooltipTrigger> - <TooltipContent> - <p className="max-w-xs text-xs"> - {vendor.vendorConditions.materialPriceRelatedReason} - </p> - </TooltipContent> - </Tooltip> - </TooltipProvider> - )} - </div> - </td> - ))} - </tr> + {/* 나머지 조건들도 동일한 패턴으로 처리 */} </tbody> </table> </CardContent> @@ -750,6 +988,250 @@ export function QuotationCompareView({ data }: QuotationCompareViewProps) { )} </TabsContent> </Tabs> + + {/* 업체 선정 모달 */} + {showSelectionDialog && ( + <div className="fixed inset-0 z-50 bg-black/50 flex items-center justify-center"> + <div className="bg-white rounded-lg p-6 max-w-lg w-full mx-4"> + <h3 className="text-lg font-semibold mb-4"> + {hasSelection ? "업체 재선정 확인" : "업체 선정 확인"} + </h3> + + {selectedVendorId && ( + <div className="space-y-4"> + <div className="rounded-lg border p-4"> + <div className="space-y-2"> + <div className="flex justify-between"> + <span className="text-sm font-medium">선정 업체</span> + <span className="text-sm font-bold"> + {data.vendors.find(v => v.vendorId === parseInt(selectedVendorId))?.vendorName} + </span> + </div> + <div className="flex justify-between"> + <span className="text-sm font-medium">견적 금액</span> + <span className="text-sm"> + {formatAmount( + data.vendors.find(v => v.vendorId === parseInt(selectedVendorId))?.totalAmount || 0, + data.vendors.find(v => v.vendorId === parseInt(selectedVendorId))?.currency + )} + </span> + </div> + <div className="flex justify-between"> + <span className="text-sm font-medium">가격 순위</span> + <span className="text-sm"> + #{data.vendors.find(v => v.vendorId === parseInt(selectedVendorId))?.rank || 0} + </span> + </div> + {data.vendors.find(v => v.vendorId === parseInt(selectedVendorId))?.conditionDifferences.hasDifferences && ( + <Alert className="mt-2"> + <AlertCircle className="h-4 w-4" /> + <AlertDescription> + 제시 조건과 차이가 있습니다. 선정 사유를 명확히 기재해주세요. + </AlertDescription> + </Alert> + )} + </div> + </div> + + <div className="space-y-2"> + <label htmlFor="selection-reason" className="text-sm font-medium"> + 선정 사유 * + </label> + <textarea + id="selection-reason" + className="w-full min-h-[100px] p-3 border rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500" + placeholder="업체 선정 사유를 입력해주세요..." + value={selectionReason} + onChange={(e) => setSelectionReason(e.target.value)} + required + /> + </div> + </div> + )} + + <div className="flex justify-end gap-2 mt-6"> + <Button + variant="outline" + onClick={() => { + setShowSelectionDialog(false); + setSelectionReason(""); + }} + disabled={isSubmitting} + > + 취소 + </Button> + <Button + onClick={handleVendorSelection} + disabled={!selectionReason || isSubmitting} + > + {isSubmitting ? "처리 중..." : hasSelection ? "재선정 확정" : "선정 확정"} + </Button> + </div> + </div> + </div> + )} + + {/* 선정 취소 모달 */} + {showCancelDialog && ( + <div className="fixed inset-0 z-50 bg-black/50 flex items-center justify-center"> + <div className="bg-white rounded-lg p-6 max-w-lg w-full mx-4"> + <h3 className="text-lg font-semibold mb-4">업체 선정 취소</h3> + + <Alert className="mb-4"> + <AlertTriangle className="h-4 w-4" /> + <AlertDescription> + 업체 선정을 취소하시겠습니까? 이 작업은 되돌릴 수 없습니다. + </AlertDescription> + </Alert> + + {selectedVendor && ( + <div className="rounded-lg border p-4 mb-4 bg-gray-50"> + <div className="space-y-2"> + <div className="flex justify-between"> + <span className="text-sm font-medium">선정 업체</span> + <span className="text-sm font-bold">{selectedVendor.vendorName}</span> + </div> + <div className="flex justify-between"> + <span className="text-sm font-medium">선정 금액</span> + <span className="text-sm"> + {formatAmount(selectedVendor.totalAmount, selectedVendor.currency)} + </span> + </div> + </div> + </div> + )} + + <div className="space-y-2"> + <label htmlFor="cancel-reason" className="text-sm font-medium"> + 취소 사유 * + </label> + <textarea + id="cancel-reason" + className="w-full min-h-[100px] p-3 border rounded-md focus:outline-none focus:ring-2 focus:ring-red-500" + placeholder="선정 취소 사유를 입력해주세요..." + value={cancelReason} + onChange={(e) => setCancelReason(e.target.value)} + required + /> + </div> + + <div className="flex justify-end gap-2 mt-6"> + <Button + variant="outline" + onClick={() => { + setShowCancelDialog(false); + setCancelReason(""); + }} + disabled={isSubmitting} + > + 닫기 + </Button> + <Button + variant="destructive" + onClick={handleCancelSelection} + disabled={!cancelReason || isSubmitting} + > + {isSubmitting ? "처리 중..." : "선정 취소"} + </Button> + </div> + </div> + </div> + )} + + {/* 계약 진행 모달 */} + {showContractDialog && ( + <div className="fixed inset-0 z-50 bg-black/50 flex items-center justify-center"> + <div className="bg-white rounded-lg p-6 max-w-lg w-full mx-4"> + <h3 className="text-lg font-semibold mb-4"> + {selectedContractType === "PO" && "PO (SAP) 생성"} + {selectedContractType === "CONTRACT" && "일반계약 생성"} + {selectedContractType === "BIDDING" && "입찰 생성"} + </h3> + + {selectedVendor && ( + <div className="space-y-4"> + <div className="rounded-lg border p-4 bg-gray-50"> + <div className="space-y-2"> + <div className="flex justify-between"> + <span className="text-sm font-medium">선정 업체</span> + <span className="text-sm font-bold">{selectedVendor.vendorName}</span> + </div> + <div className="flex justify-between"> + <span className="text-sm font-medium">계약 금액</span> + <span className="text-sm"> + {formatAmount(selectedVendor.totalAmount, selectedVendor.currency)} + </span> + </div> + </div> + </div> + + <Alert> + <Info className="h-4 w-4" /> + <AlertDescription> + {selectedContractType === "PO" && ( + "PO를 생성하면 SAP 시스템으로 자동 전송됩니다. 계속 진행하시겠습니까?" + )} + {selectedContractType === "CONTRACT" && ( + "일반계약을 생성하면 계약서 작성 프로세스가 시작됩니다. 계속 진행하시겠습니까?" + )} + {selectedContractType === "BIDDING" && ( + "입찰을 생성하면 입찰 공고 프로세스가 시작됩니다. 계속 진행하시겠습니까?" + )} + </AlertDescription> + </Alert> + + {/* 추가 옵션이 필요한 경우 여기에 추가 */} + {selectedContractType === "CONTRACT" && ( + <div className="space-y-2"> + <p className="text-sm font-medium">계약 옵션</p> + <div className="space-y-2 text-sm"> + <label className="flex items-center gap-2"> + <input type="checkbox" className="rounded" /> + <span>표준계약서 사용</span> + </label> + <label className="flex items-center gap-2"> + <input type="checkbox" className="rounded" /> + <span>전자서명 요청</span> + </label> + </div> + </div> + )} + + {selectedContractType === "BIDDING" && ( + <div className="space-y-2"> + <p className="text-sm font-medium">입찰 유형</p> + <select className="w-full px-3 py-2 border rounded-md"> + <option value="">선택하세요</option> + <option value="open">공개입찰</option> + <option value="limited">제한입찰</option> + <option value="private">지명입찰</option> + </select> + </div> + )} + </div> + )} + + <div className="flex justify-end gap-2 mt-6"> + <Button + variant="outline" + onClick={() => { + setShowContractDialog(false); + setSelectedContractType(""); + }} + disabled={isSubmitting} + > + 취소 + </Button> + <Button + onClick={handleContractCreation} + disabled={isSubmitting} + > + {isSubmitting ? "처리 중..." : "진행"} + </Button> + </div> + </div> + </div> + )} </div> ); }
\ No newline at end of file diff --git a/lib/rfq-last/service.ts b/lib/rfq-last/service.ts index 433f4376..723a69fe 100644 --- a/lib/rfq-last/service.ts +++ b/lib/rfq-last/service.ts @@ -268,7 +268,7 @@ async function generateGeneralRfqCode(userCode: string): Promise<string> { .from(rfqsLast) .where( and( - eq(rfqsLast.rfqType, "일반견적"), + // eq(rfqsLast.rfqType, "일반견적"), like(rfqsLast.rfqCode, `F${userCode}%`) // 같은 userCode로 시작하는 RFQ만 조회 ) ) @@ -450,7 +450,7 @@ export async function previewGeneralRfqCode(picUserId: number): Promise<string> .from(rfqsLast) .where( and( - eq(rfqsLast.rfqType, "일반견적"), + // eq(rfqsLast.rfqType, "일반견적"), like(rfqsLast.rfqCode, `F${userCode}%`) ) ) @@ -459,6 +459,10 @@ export async function previewGeneralRfqCode(picUserId: number): Promise<string> let nextNumber = 1; + console.log(lastRfq,"lastRfq") + console.log(userCode,"userCode") + + if (lastRfq.length > 0 && lastRfq[0].rfqCode) { const rfqCode = lastRfq[0].rfqCode; const serialNumber = rfqCode.slice(-5); @@ -471,6 +475,7 @@ export async function previewGeneralRfqCode(picUserId: number): Promise<string> const paddedNumber = String(nextNumber).padStart(5, '0'); return `F${userCode}${paddedNumber}`; } catch (error) { + console.log(error) return `F???XXXXX`; } } diff --git a/lib/rfq-last/vendor/rfq-vendor-table.tsx b/lib/rfq-last/vendor/rfq-vendor-table.tsx index d451b2ba..88ae968a 100644 --- a/lib/rfq-last/vendor/rfq-vendor-table.tsx +++ b/lib/rfq-last/vendor/rfq-vendor-table.tsx @@ -349,10 +349,14 @@ export function RfqVendorTable({ setIsLoadingSendData(true); // 선택된 벤더 ID들 추출 - const selectedVendorIds = selectedRows + const selectedVendorIds = rfqCode?.startsWith("I")? selectedRows .filter(v=>v.shortList) .map(row => row.vendorId) - .filter(id => id != null); + .filter(id => id != null) : + selectedRows + .map(row => row.vendorId) + .filter(id => id != null) + if (selectedVendorIds.length === 0) { toast.error("유효한 벤더가 선택되지 않았습니다."); @@ -1029,7 +1033,7 @@ export function RfqVendorTable({ }, size: 80, }, - { + ...(rfqCode?.startsWith("I") ? [{ accessorKey: "shortList", header: ({ column }) => <ClientDataTableColumnHeaderSimple column={column} title="Short List" />, cell: ({ row }) => ( @@ -1040,22 +1044,7 @@ export function RfqVendorTable({ ) ), size: 80, - }, - { - accessorKey: "updatedAt", - header: ({ column }) => <ClientDataTableColumnHeaderSimple column={column} title="최신수정일" />, - cell: ({ row }) => { - const date = row.original.updatedAt; - return date ? ( - <span className="text-xs text-muted-foreground"> - {format(new Date(date), "MM-dd HH:mm")} - </span> - ) : ( - <span className="text-muted-foreground">-</span> - ); - }, - size: 100, - }, + }] : []), { accessorKey: "updatedByUserName", header: ({ column }) => <ClientDataTableColumnHeaderSimple column={column} title="최신수정자" />, @@ -1217,6 +1206,7 @@ export function RfqVendorTable({ {selectedRows.length > 0 && ( <> {/* Short List 확정 버튼 */} + {rfqCode?.startsWith("I")&& <Button variant="outline" size="sm" @@ -1237,6 +1227,7 @@ export function RfqVendorTable({ </> )} </Button> + } {/* 견적 비교 버튼 */} <Button diff --git a/lib/tbe-last/service.ts b/lib/tbe-last/service.ts index d9046524..32d5a5f5 100644 --- a/lib/tbe-last/service.ts +++ b/lib/tbe-last/service.ts @@ -11,6 +11,10 @@ import { filterColumns } from "@/lib/filter-columns"; import { GetTBELastSchema } from "./validations"; import { getServerSession } from "next-auth" import { authOptions } from "@/app/api/auth/[...nextauth]/route" +import path from "path" +import fs from "fs/promises" +import { sendEmail } from "../mail/sendEmail"; + // ========================================== // 1. TBE 세션 목록 조회 // ========================================== @@ -415,4 +419,132 @@ function mapReviewStatus(status: string | null): string { } return status ? (statusMap[status] || status) : "미검토" +} + + +interface DocumentInfo { + documentId: number + documentReviewId: number + documentName: string + filePath: string + documentType: string + documentSource: string + reviewStatus?: string +} + +interface SessionInfo { + sessionId: number + sessionTitle: string + buyerName: string + vendorName: string +} + +interface SendDocumentsEmailParams { + to: string[] + cc?: string[] + documents: DocumentInfo[] + comments?: string + sessionInfo: SessionInfo +} + +export async function sendDocumentsEmail({ + to, + cc, + documents, + comments, + sessionInfo +}: SendDocumentsEmailParams) { + try { + // 사용자 인증 확인 + const session = await getServerSession(authOptions) + if (!session?.user) { + return { success: false, error: "인증이 필요합니다" } + } + + + // 첨부 파일 준비 + const attachments = await Promise.all( + documents.map(async (doc) => { + try { + // 실제 파일 경로 구성 (프로젝트 구조에 맞게 조정 필요) + + const isDev = process.env.NODE_ENV === 'development'; + + const filePath = isDev ? path.join(process.cwd(), 'public', doc.filePath) + :path.join(`${process.env.NAS_PATH}`, doc.filePath) + + // 파일 존재 확인 + await fs.access(filePath) + + // 파일 읽기 + const content = await fs.readFile(filePath) + + return { + filename: doc.documentName, + content: content, + encoding: 'base64' + } + } catch (error) { + console.error(`Failed to attach file: ${doc.documentName}`, error) + // 파일을 찾을 수 없는 경우 건너뛰기 + return null + } + }) + ) + + // null 값 필터링 + const validAttachments = attachments.filter(att => att !== null) + + // 이메일 전송 + const result = await sendEmail({ + to: to.join(", "), + cc: cc?.join(", "), + template: "document-share", // 템플릿 이름 + context: { + senderName: session.user.name || "TBE User", + senderEmail: session.user.email, + sessionTitle: sessionInfo.sessionTitle, + sessionId: sessionInfo.sessionId, + buyerName: sessionInfo.buyerName, + vendorName: sessionInfo.vendorName, + documentCount: documents.length, + documents: documents.map(doc => ({ + name: doc.documentName, + type: doc.documentType, + source: doc.documentSource, + reviewStatus: doc.reviewStatus || "미검토", + reviewStatusClass: getReviewStatusClass(doc.reviewStatus), + })), + comments: comments || "", + hasComments: !!comments, + language: "ko", // 한국어로 설정 + year: new Date().getFullYear(), + }, + attachments: validAttachments as any + }) + + return { success: true, data: result } + } catch (error) { + console.error("Failed to send documents email:", error) + return { + success: false, + error: error instanceof Error ? error.message : "Failed to send email" + } + } +} + +// 리뷰 상태에 따른 CSS 클래스 반환 +function getReviewStatusClass(status?: string): string { + switch (status) { + case "승인": + return "approved" + case "반려": + return "rejected" + case "보류": + return "pending" + case "검토중": + return "reviewing" + default: + return "unreviewed" + } }
\ No newline at end of file diff --git a/lib/tbe-last/table/documents-sheet.tsx b/lib/tbe-last/table/documents-sheet.tsx index 96e6e178..ac0dc739 100644 --- a/lib/tbe-last/table/documents-sheet.tsx +++ b/lib/tbe-last/table/documents-sheet.tsx @@ -36,6 +36,7 @@ import { DropdownMenuTrigger, } from "@/components/ui/dropdown-menu" import { ScrollArea } from "@/components/ui/scroll-area" +import { Checkbox } from "@/components/ui/checkbox" import { formatDate } from "@/lib/utils" import { downloadFile, getFileInfo } from "@/lib/file-download" import { @@ -50,9 +51,11 @@ import { Clock, AlertCircle, Save, + Mail, } from "lucide-react" import { toast } from "sonner" import { useRouter } from "next/navigation" +import { EmailDocumentsDialog } from "./email-documents-dialog" interface DocumentsSheetProps { open: boolean @@ -81,10 +84,17 @@ export function DocumentsSheet({ reviewComments: string }>>({}) const [isSaving, setIsSaving] = React.useState<Record<number, boolean>>({}) - const [commentCounts, setCommentCounts] = React.useState<CountMap>({}) // <-- 추가 + const [commentCounts, setCommentCounts] = React.useState<CountMap>({}) const [countLoading, setCountLoading] = React.useState(false) + + // 새로 추가된 state들 + const [selectedDocuments, setSelectedDocuments] = React.useState<Set<number>>(new Set()) + const [emailDialogOpen, setEmailDialogOpen] = React.useState(false) + const router = useRouter() + // ... (기존 useEffect와 함수들은 그대로 유지) + const allReviewIds = React.useMemo(() => { const docs = sessionDetail?.documents ?? [] const ids = new Set<number>() @@ -104,7 +114,6 @@ export function DocumentsSheet({ } setCountLoading(true) try { - // 너무 길어질 수 있으니 적당히 나눠서 호출(옵션) const chunkSize = 100 const chunks: number[][] = [] for (let i = 0; i < allReviewIds.length; i += chunkSize) { @@ -139,9 +148,8 @@ export function DocumentsSheet({ return () => { aborted = true } - }, [allReviewIds.join(",")]) // 의존성: id 목록이 바뀔 때만 + }, [allReviewIds.join(",")]) - // 문서 초기 데이터 설정 React.useEffect(() => { if (sessionDetail?.documents) { const initialData: Record<number, any> = {} @@ -155,7 +163,6 @@ export function DocumentsSheet({ } }, [sessionDetail]) - // PDFtron 뷰어 열기 const handleOpenPDFTron = (doc: any) => { if (!doc.filePath) { toast.error("파일 경로를 찾을 수 없습니다") @@ -174,7 +181,6 @@ export function DocumentsSheet({ window.open(`/pdftron-viewer?${params.toString()}`, '_blank') } - // 파일이 PDFtron에서 열 수 있는지 확인 const canOpenInPDFTron = (filePath: string) => { if (!filePath) return false const ext = filePath.split('.').pop()?.toLowerCase() @@ -182,7 +188,6 @@ export function DocumentsSheet({ return supportedFormats.includes(ext || '') } - // 파일 다운로드 const handleDownload = async (doc: any) => { if (!doc.filePath) { toast.error("파일 경로를 찾을 수 없습니다") @@ -198,13 +203,11 @@ export function DocumentsSheet({ }) } - // 리뷰 상태 저장 const handleSaveReview = async (doc: any) => { const reviewId = doc.documentReviewId setIsSaving({ ...isSaving, [reviewId]: true }) try { - // API 호출하여 리뷰 상태 저장 const response = await fetch(`/api/document-reviews/${reviewId}`, { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, @@ -227,7 +230,6 @@ export function DocumentsSheet({ } } - // 리뷰 상태 아이콘 const getReviewStatusIcon = (status: string) => { switch (status) { case "승인": @@ -241,17 +243,40 @@ export function DocumentsSheet({ } } - // 필터링된 문서 목록 + // 문서 선택 관련 함수들 + const handleSelectDocument = (documentId: number, checked: boolean) => { + const newSelected = new Set(selectedDocuments) + if (checked) { + newSelected.add(documentId) + } else { + newSelected.delete(documentId) + } + setSelectedDocuments(newSelected) + } + + const handleSelectAll = (checked: boolean) => { + if (checked) { + const allIds = new Set(filteredDocuments.map((doc: any) => doc.documentReviewId)) + setSelectedDocuments(allIds) + } else { + setSelectedDocuments(new Set()) + } + } + + const getSelectedDocumentDetails = () => { + return filteredDocuments.filter((doc: any) => + selectedDocuments.has(doc.documentReviewId) + ) + } + const filteredDocuments = React.useMemo(() => { if (!sessionDetail?.documents) return [] return sessionDetail.documents.filter((doc: any) => { - // Source 필터 if (sourceFilter !== "all" && doc.documentSource !== sourceFilter) { return false } - // 검색어 필터 if (searchTerm) { const searchLower = searchTerm.toLowerCase() return ( @@ -265,279 +290,325 @@ export function DocumentsSheet({ }) }, [sessionDetail?.documents, sourceFilter, searchTerm]) - return ( - <Sheet open={open} onOpenChange={onOpenChange}> - <SheetContent className="w-[1200px] sm:w-[1200px] max-w-[90vw]" style={{ width: 1200, maxWidth: "90vw" }}> - <SheetHeader> - <SheetTitle>Documents & Review Management</SheetTitle> - <SheetDescription> - 문서 검토 및 코멘트 관리 - </SheetDescription> - </SheetHeader> - - {/* 필터 및 검색 */} - <div className="flex items-center gap-4 mt-4 mb-4"> - <div className="flex items-center gap-2"> - <Filter className="h-4 w-4 text-muted-foreground" /> - <Select value={sourceFilter} onValueChange={(value: any) => setSourceFilter(value)}> - <SelectTrigger className="w-[150px]"> - <SelectValue placeholder="Filter by source" /> - </SelectTrigger> - <SelectContent> - <SelectItem value="all">All Documents</SelectItem> - <SelectItem value="buyer">Buyer Documents</SelectItem> - <SelectItem value="vendor">Vendor Documents</SelectItem> - </SelectContent> - </Select> - </div> + const allSelected = filteredDocuments.length > 0 && + filteredDocuments.every((doc: any) => selectedDocuments.has(doc.documentReviewId)) + const someSelected = filteredDocuments.some((doc: any) => + selectedDocuments.has(doc.documentReviewId)) && !allSelected - <Input - placeholder="Search documents..." - value={searchTerm} - onChange={(e) => setSearchTerm(e.target.value)} - className="max-w-sm" - /> - - <div className="ml-auto flex items-center gap-2 text-sm text-muted-foreground"> - <Badge variant="outline"> - Total: {filteredDocuments.length} - </Badge> - {sessionDetail?.documents && ( - <> - <Badge variant="secondary"> - Buyer: {sessionDetail.documents.filter((d: any) => d.documentSource === "buyer").length} - </Badge> - <Badge variant="secondary"> - Vendor: {sessionDetail.documents.filter((d: any) => d.documentSource === "vendor").length} - </Badge> - </> + return ( + <> + <Sheet open={open} onOpenChange={onOpenChange}> + <SheetContent className="w-[1200px] sm:w-[1200px] max-w-[90vw]" style={{ width: 1200, maxWidth: "90vw" }}> + <SheetHeader> + <SheetTitle>Documents & Review Management</SheetTitle> + <SheetDescription> + 문서 검토 및 코멘트 관리 + </SheetDescription> + </SheetHeader> + + {/* 필터 및 검색 */} + <div className="flex items-center gap-4 mt-4 mb-4"> + <div className="flex items-center gap-2"> + <Filter className="h-4 w-4 text-muted-foreground" /> + <Select value={sourceFilter} onValueChange={(value: any) => setSourceFilter(value)}> + <SelectTrigger className="w-[150px]"> + <SelectValue placeholder="Filter by source" /> + </SelectTrigger> + <SelectContent> + <SelectItem value="all">All Documents</SelectItem> + <SelectItem value="buyer">Buyer Documents</SelectItem> + <SelectItem value="vendor">Vendor Documents</SelectItem> + </SelectContent> + </Select> + </div> + + <Input + placeholder="Search documents..." + value={searchTerm} + onChange={(e) => setSearchTerm(e.target.value)} + className="max-w-sm" + /> + + {/* 이메일 보내기 버튼 추가 */} + {selectedDocuments.size > 0 && ( + <Button + onClick={() => setEmailDialogOpen(true)} + variant="default" + size="sm" + className="ml-2" + > + <Mail className="h-4 w-4 mr-2" /> + Send Email ({selectedDocuments.size}) + </Button> )} + + <div className="ml-auto flex items-center gap-2 text-sm text-muted-foreground"> + <Badge variant="outline"> + Total: {filteredDocuments.length} + </Badge> + {sessionDetail?.documents && ( + <> + <Badge variant="secondary"> + Buyer: {sessionDetail.documents.filter((d: any) => d.documentSource === "buyer").length} + </Badge> + <Badge variant="secondary"> + Vendor: {sessionDetail.documents.filter((d: any) => d.documentSource === "vendor").length} + </Badge> + </> + )} + </div> </div> - </div> - - {/* 문서 테이블 */} - {isLoading ? ( - <div className="p-8 text-center">Loading...</div> - ) : ( - <ScrollArea className="h-[calc(100vh-250px)]"> - <Table> - <TableHeader> - <TableRow> - <TableHead className="w-[100px]">Source</TableHead> - <TableHead>Document Name</TableHead> - <TableHead className="w-[100px]">Type</TableHead> - <TableHead className="w-[120px]">Review Status</TableHead> - <TableHead className="w-[120px]">Comments</TableHead> - <TableHead className="w-[200px]">Review Notes</TableHead> - <TableHead className="w-[120px]">Uploaded</TableHead> - <TableHead className="w-[100px] text-right">Actions</TableHead> - </TableRow> - </TableHeader> - <TableBody> - {filteredDocuments.length === 0 ? ( + + {/* 문서 테이블 */} + {isLoading ? ( + <div className="p-8 text-center">Loading...</div> + ) : ( + <ScrollArea className="h-[calc(100vh-250px)]"> + <Table> + <TableHeader> <TableRow> - <TableCell colSpan={8} className="text-center text-muted-foreground"> - No documents found - </TableCell> + <TableHead className="w-[50px]"> + <Checkbox + checked={allSelected} + indeterminate={someSelected} + onCheckedChange={handleSelectAll} + /> + </TableHead> + <TableHead className="w-[100px]">Source</TableHead> + <TableHead>Document Name</TableHead> + <TableHead className="w-[100px]">Type</TableHead> + <TableHead className="w-[120px]">Review Status</TableHead> + <TableHead className="w-[120px]">Comments</TableHead> + <TableHead className="w-[200px]">Review Notes</TableHead> + <TableHead className="w-[120px]">Uploaded</TableHead> + <TableHead className="w-[100px] text-right">Actions</TableHead> </TableRow> - ) : ( - filteredDocuments.map((doc: any) => ( - <TableRow key={doc.documentReviewId}> - <TableCell> - <Badge variant={doc.documentSource === "buyer" ? "default" : "secondary"}> - {doc.documentSource} - </Badge> - </TableCell> - - <TableCell> - <div className="flex items-center gap-2"> - <FileText className="h-4 w-4 text-muted-foreground" /> - <span className="font-medium">{doc.documentName}</span> - </div> + </TableHeader> + <TableBody> + {filteredDocuments.length === 0 ? ( + <TableRow> + <TableCell colSpan={9} className="text-center text-muted-foreground"> + No documents found </TableCell> - - <TableCell>{doc.documentType}</TableCell> - - <TableCell> - {editingReviewId === doc.documentReviewId ? ( - <Select - value={reviewData[doc.documentReviewId]?.reviewStatus || "미검토"} - onValueChange={(value) => { - setReviewData({ - ...reviewData, - [doc.documentReviewId]: { - ...reviewData[doc.documentReviewId], - reviewStatus: value - } - }) - }} - > - <SelectTrigger className="w-[110px] h-8"> - <SelectValue /> - </SelectTrigger> - <SelectContent> - <SelectItem value="미검토">미검토</SelectItem> - <SelectItem value="검토중">검토중</SelectItem> - <SelectItem value="승인">승인</SelectItem> - <SelectItem value="반려">반려</SelectItem> - <SelectItem value="보류">보류</SelectItem> - </SelectContent> - </Select> - ) : ( - <div className="flex items-center gap-1"> - {getReviewStatusIcon(reviewData[doc.documentReviewId]?.reviewStatus || doc.reviewStatus)} - <span className="text-sm"> - {reviewData[doc.documentReviewId]?.reviewStatus || doc.reviewStatus || "미검토"} - </span> + </TableRow> + ) : ( + filteredDocuments.map((doc: any) => ( + <TableRow key={doc.documentReviewId}> + <TableCell> + <Checkbox + checked={selectedDocuments.has(doc.documentReviewId)} + onCheckedChange={(checked) => + handleSelectDocument(doc.documentReviewId, checked as boolean) + } + /> + </TableCell> + + <TableCell> + <Badge variant={doc.documentSource === "buyer" ? "default" : "secondary"}> + {doc.documentSource} + </Badge> + </TableCell> + + <TableCell> + <div className="flex items-center gap-2"> + <FileText className="h-4 w-4 text-muted-foreground" /> + <span className="font-medium">{doc.documentName}</span> </div> - )} - </TableCell> - - - <TableCell> - {(() => { - const id = Number(doc.documentReviewId) - const counts = Number.isFinite(id) ? commentCounts[id] : undefined - if (countLoading && !counts) { - return <span className="text-xs text-muted-foreground">Loading…</span> - } - if (!counts || counts.totalCount === 0) { - return <span className="text-muted-foreground text-xs">-</span> - } - return ( + </TableCell> + + <TableCell>{doc.documentType}</TableCell> + + <TableCell> + {editingReviewId === doc.documentReviewId ? ( + <Select + value={reviewData[doc.documentReviewId]?.reviewStatus || "미검토"} + onValueChange={(value) => { + setReviewData({ + ...reviewData, + [doc.documentReviewId]: { + ...reviewData[doc.documentReviewId], + reviewStatus: value + } + }) + }} + > + <SelectTrigger className="w-[110px] h-8"> + <SelectValue /> + </SelectTrigger> + <SelectContent> + <SelectItem value="미검토">미검토</SelectItem> + <SelectItem value="검토중">검토중</SelectItem> + <SelectItem value="승인">승인</SelectItem> + <SelectItem value="반려">반려</SelectItem> + <SelectItem value="보류">보류</SelectItem> + </SelectContent> + </Select> + ) : ( <div className="flex items-center gap-1"> - <MessageSquare className="h-3 w-3" /> - <span className="text-xs"> - {counts.totalCount} - {counts.openCount > 0 && ( - <span className="text-orange-600 ml-1"> - ({counts.openCount} open) - </span> - )} + {getReviewStatusIcon(reviewData[doc.documentReviewId]?.reviewStatus || doc.reviewStatus)} + <span className="text-sm"> + {reviewData[doc.documentReviewId]?.reviewStatus || doc.reviewStatus || "미검토"} </span> </div> - ) - })()} - </TableCell> - - <TableCell> - {editingReviewId === doc.documentReviewId ? ( - <Textarea - value={reviewData[doc.documentReviewId]?.reviewComments || ""} - onChange={(e) => { - setReviewData({ - ...reviewData, - [doc.documentReviewId]: { - ...reviewData[doc.documentReviewId], - reviewComments: e.target.value - } - }) - }} - placeholder="리뷰 코멘트 입력..." - className="min-h-[60px] text-xs" - /> - ) : ( - <p className="text-xs text-muted-foreground truncate max-w-[200px]" - title={reviewData[doc.documentReviewId]?.reviewComments || doc.reviewComments}> - {reviewData[doc.documentReviewId]?.reviewComments || doc.reviewComments || "-"} - </p> - )} - </TableCell> - - <TableCell> - <span className="text-xs text-muted-foreground"> - {doc.uploadedAt ? formatDate(doc.uploadedAt, "KR") : - doc.submittedAt ? formatDate(doc.submittedAt, "KR") : "-"} - </span> - </TableCell> + )} + </TableCell> + + <TableCell> + {(() => { + const id = Number(doc.documentReviewId) + const counts = Number.isFinite(id) ? commentCounts[id] : undefined + if (countLoading && !counts) { + return <span className="text-xs text-muted-foreground">Loading…</span> + } + if (!counts || counts.totalCount === 0) { + return <span className="text-muted-foreground text-xs">-</span> + } + return ( + <div className="flex items-center gap-1"> + <MessageSquare className="h-3 w-3" /> + <span className="text-xs"> + {counts.totalCount} + {counts.openCount > 0 && ( + <span className="text-orange-600 ml-1"> + ({counts.openCount} open) + </span> + )} + </span> + </div> + ) + })()} + </TableCell> + + <TableCell> + {editingReviewId === doc.documentReviewId ? ( + <Textarea + value={reviewData[doc.documentReviewId]?.reviewComments || ""} + onChange={(e) => { + setReviewData({ + ...reviewData, + [doc.documentReviewId]: { + ...reviewData[doc.documentReviewId], + reviewComments: e.target.value + } + }) + }} + placeholder="리뷰 코멘트 입력..." + className="min-h-[60px] text-xs" + /> + ) : ( + <p className="text-xs text-muted-foreground truncate max-w-[200px]" + title={reviewData[doc.documentReviewId]?.reviewComments || doc.reviewComments}> + {reviewData[doc.documentReviewId]?.reviewComments || doc.reviewComments || "-"} + </p> + )} + </TableCell> + + <TableCell> + <span className="text-xs text-muted-foreground"> + {doc.uploadedAt ? formatDate(doc.uploadedAt, "KR") : + doc.submittedAt ? formatDate(doc.submittedAt, "KR") : "-"} + </span> + </TableCell> + + <TableCell className="text-right"> + <div className="flex items-center justify-end gap-1"> + {canOpenInPDFTron(doc.filePath) ? ( + <Button + size="sm" + variant="ghost" + onClick={() => handleOpenPDFTron(doc)} + className="h-8 px-2" + > + <Eye className="h-4 w-4" /> + </Button> + ) : null} - <TableCell className="text-right"> - <div className="flex items-center justify-end gap-1"> - {canOpenInPDFTron(doc.filePath) ? ( <Button size="sm" variant="ghost" - onClick={() => handleOpenPDFTron(doc)} + onClick={() => handleDownload(doc)} className="h-8 px-2" > - <Eye className="h-4 w-4" /> + <Download className="h-4 w-4" /> </Button> - ) : null} - - <Button - size="sm" - variant="ghost" - onClick={() => handleDownload(doc)} - className="h-8 px-2" - > - <Download className="h-4 w-4" /> - </Button> - - <DropdownMenu> - <DropdownMenuTrigger asChild> - <Button variant="ghost" size="sm" className="h-8 px-2"> - <MoreHorizontal className="h-4 w-4" /> - </Button> - </DropdownMenuTrigger> - <DropdownMenuContent align="end"> - {editingReviewId === doc.documentReviewId ? ( - <> + + <DropdownMenu> + <DropdownMenuTrigger asChild> + <Button variant="ghost" size="sm" className="h-8 px-2"> + <MoreHorizontal className="h-4 w-4" /> + </Button> + </DropdownMenuTrigger> + <DropdownMenuContent align="end"> + {editingReviewId === doc.documentReviewId ? ( + <> + <DropdownMenuItem + onClick={() => handleSaveReview(doc)} + disabled={isSaving[doc.documentReviewId]} + > + <Save className="h-4 w-4 mr-2" /> + {isSaving[doc.documentReviewId] ? "저장 중..." : "저장"} + </DropdownMenuItem> + <DropdownMenuItem + onClick={() => { + setEditingReviewId(null) + setReviewData({ + ...reviewData, + [doc.documentReviewId]: { + reviewStatus: doc.reviewStatus || "미검토", + reviewComments: doc.reviewComments || "" + } + }) + }} + > + <XCircle className="h-4 w-4 mr-2" /> + 취소 + </DropdownMenuItem> + </> + ) : ( <DropdownMenuItem - onClick={() => handleSaveReview(doc)} - disabled={isSaving[doc.documentReviewId]} + onClick={() => setEditingReviewId(doc.documentReviewId)} > - <Save className="h-4 w-4 mr-2" /> - {isSaving[doc.documentReviewId] ? "저장 중..." : "저장"} + <MessageSquare className="h-4 w-4 mr-2" /> + 리뷰 편집 </DropdownMenuItem> - <DropdownMenuItem - onClick={() => { - setEditingReviewId(null) - // 원래 값으로 복원 - setReviewData({ - ...reviewData, - [doc.documentReviewId]: { - reviewStatus: doc.reviewStatus || "미검토", - reviewComments: doc.reviewComments || "" - } - }) - }} - > - <XCircle className="h-4 w-4 mr-2" /> - 취소 + )} + + {canOpenInPDFTron(doc.filePath) && ( + <DropdownMenuItem onClick={() => handleOpenPDFTron(doc)}> + <Eye className="h-4 w-4 mr-2" /> + PDFTron에서 보기 </DropdownMenuItem> - </> - ) : ( - <DropdownMenuItem - onClick={() => setEditingReviewId(doc.documentReviewId)} - > - <MessageSquare className="h-4 w-4 mr-2" /> - 리뷰 편집 - </DropdownMenuItem> - )} + )} - {canOpenInPDFTron(doc.filePath) && ( - <DropdownMenuItem onClick={() => handleOpenPDFTron(doc)}> - <Eye className="h-4 w-4 mr-2" /> - PDFTron에서 보기 + <DropdownMenuItem onClick={() => handleDownload(doc)}> + <Download className="h-4 w-4 mr-2" /> + 다운로드 </DropdownMenuItem> - )} - - <DropdownMenuItem onClick={() => handleDownload(doc)}> - <Download className="h-4 w-4 mr-2" /> - 다운로드 - </DropdownMenuItem> - </DropdownMenuContent> - </DropdownMenu> - </div> - </TableCell> - </TableRow> - )) - )} - </TableBody> - </Table> - </ScrollArea> - )} - </SheetContent> - </Sheet> + </DropdownMenuContent> + </DropdownMenu> + </div> + </TableCell> + </TableRow> + )) + )} + </TableBody> + </Table> + </ScrollArea> + )} + </SheetContent> + </Sheet> + + {/* 이메일 전송 다이얼로그 */} + <EmailDocumentsDialog + open={emailDialogOpen} + onOpenChange={setEmailDialogOpen} + selectedDocuments={getSelectedDocumentDetails()} + sessionDetail={sessionDetail} + onSuccess={() => { + setSelectedDocuments(new Set()) + setEmailDialogOpen(false) + }} + /> + </> ) }
\ No newline at end of file diff --git a/lib/tbe-last/table/email-documents-dialog.tsx b/lib/tbe-last/table/email-documents-dialog.tsx new file mode 100644 index 00000000..415cd428 --- /dev/null +++ b/lib/tbe-last/table/email-documents-dialog.tsx @@ -0,0 +1,334 @@ +// lib/tbe-last/table/dialogs/email-documents-dialog.tsx + +"use client" + +import * as React from "react" +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 { Badge } from "@/components/ui/badge" +import { ScrollArea } from "@/components/ui/scroll-area" +import { + FileText, + X, + Plus, + Mail, + Loader2, + AlertCircle, +} from "lucide-react" +import { toast } from "sonner" +import { sendDocumentsEmail } from "../service" + +interface EmailDocumentsDialogProps { + open: boolean + onOpenChange: (open: boolean) => void + selectedDocuments: any[] + sessionDetail: any + onSuccess?: () => void +} + +export function EmailDocumentsDialog({ + open, + onOpenChange, + selectedDocuments, + sessionDetail, + onSuccess +}: EmailDocumentsDialogProps) { + const [recipients, setRecipients] = React.useState<string[]>([]) + const [currentEmail, setCurrentEmail] = React.useState("") + const [ccRecipients, setCcRecipients] = React.useState<string[]>([]) + const [currentCc, setCurrentCc] = React.useState("") + const [comments, setComments] = React.useState("") + const [isLoading, setIsLoading] = React.useState(false) + + // 이메일 유효성 검사 + const validateEmail = (email: string) => { + const re = /^[^\s@]+@[^\s@]+\.[^\s@]+$/ + return re.test(email) + } + + // 수신자 추가 + const handleAddRecipient = () => { + if (currentEmail && validateEmail(currentEmail)) { + if (!recipients.includes(currentEmail)) { + setRecipients([...recipients, currentEmail]) + setCurrentEmail("") + } else { + toast.error("이미 추가된 이메일입니다") + } + } else { + toast.error("올바른 이메일 주소를 입력하세요") + } + } + + // CC 수신자 추가 + const handleAddCc = () => { + if (currentCc && validateEmail(currentCc)) { + if (!ccRecipients.includes(currentCc)) { + setCcRecipients([...ccRecipients, currentCc]) + setCurrentCc("") + } else { + toast.error("이미 추가된 이메일입니다") + } + } else { + toast.error("올바른 이메일 주소를 입력하세요") + } + } + + // 수신자 제거 + const removeRecipient = (email: string) => { + setRecipients(recipients.filter(r => r !== email)) + } + + // CC 수신자 제거 + const removeCc = (email: string) => { + setCcRecipients(ccRecipients.filter(r => r !== email)) + } + + // 이메일 전송 + const handleSendEmail = async () => { + if (recipients.length === 0) { + toast.error("최소 한 명의 수신자를 추가하세요") + return + } + + if (selectedDocuments.length === 0) { + toast.error("선택된 문서가 없습니다") + return + } + + setIsLoading(true) + + try { + const result = await sendDocumentsEmail({ + to: recipients, + cc: ccRecipients.length > 0 ? ccRecipients : undefined, + documents: selectedDocuments.map(doc => ({ + documentId: doc.documentId, + documentReviewId: doc.documentReviewId, + documentName: doc.documentName, + filePath: doc.filePath, + documentType: doc.documentType, + documentSource: doc.documentSource, + reviewStatus: doc.reviewStatus, + })), + comments, + sessionInfo: { + sessionId: sessionDetail?.session?.tbeSessionId, + sessionTitle: sessionDetail?.session?.title, + buyerName: sessionDetail?.session?.buyerName, + vendorName: sessionDetail?.session?.vendorName, + } + }) + + if (result.success) { + toast.success("이메일이 성공적으로 전송되었습니다") + onSuccess?.() + onOpenChange(false) + + // 초기화 + setRecipients([]) + setCcRecipients([]) + setComments("") + setCurrentEmail("") + setCurrentCc("") + } else { + throw new Error(result.error || "이메일 전송 실패") + } + } catch (error) { + console.error("Email send error:", error) + toast.error(error instanceof Error ? error.message : "이메일 전송 중 오류가 발생했습니다") + } finally { + setIsLoading(false) + } + } + + // 파일 크기 포맷 + 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 Math.round(bytes / Math.pow(k, i) * 100) / 100 + ' ' + sizes[i] + } + + return ( + <Dialog open={open} onOpenChange={onOpenChange}> + <DialogContent className="max-w-2xl"> + <DialogHeader> + <DialogTitle>Send Documents via Email</DialogTitle> + <DialogDescription> + 선택한 {selectedDocuments.length}개의 문서를 이메일로 전송합니다 + </DialogDescription> + </DialogHeader> + + <div className="grid gap-4 py-4"> + {/* 수신자 입력 */} + <div className="grid gap-2"> + <Label htmlFor="recipients">수신자 (To) *</Label> + <div className="flex gap-2"> + <Input + id="recipients" + type="email" + placeholder="이메일 주소 입력" + value={currentEmail} + onChange={(e) => setCurrentEmail(e.target.value)} + onKeyPress={(e) => { + if (e.key === 'Enter') { + e.preventDefault() + handleAddRecipient() + } + }} + /> + <Button + type="button" + size="sm" + onClick={handleAddRecipient} + variant="outline" + > + <Plus className="h-4 w-4" /> + </Button> + </div> + <div className="flex flex-wrap gap-2 mt-2"> + {recipients.map((email) => ( + <Badge key={email} variant="secondary" className="gap-1"> + {email} + <X + className="h-3 w-3 cursor-pointer hover:text-destructive" + onClick={() => removeRecipient(email)} + /> + </Badge> + ))} + </div> + </div> + + {/* CC 입력 */} + <div className="grid gap-2"> + <Label htmlFor="cc">참조 (CC)</Label> + <div className="flex gap-2"> + <Input + id="cc" + type="email" + placeholder="이메일 주소 입력 (선택사항)" + value={currentCc} + onChange={(e) => setCurrentCc(e.target.value)} + onKeyPress={(e) => { + if (e.key === 'Enter') { + e.preventDefault() + handleAddCc() + } + }} + /> + <Button + type="button" + size="sm" + onClick={handleAddCc} + variant="outline" + > + <Plus className="h-4 w-4" /> + </Button> + </div> + <div className="flex flex-wrap gap-2 mt-2"> + {ccRecipients.map((email) => ( + <Badge key={email} variant="secondary" className="gap-1"> + {email} + <X + className="h-3 w-3 cursor-pointer hover:text-destructive" + onClick={() => removeCc(email)} + /> + </Badge> + ))} + </div> + </div> + + {/* 코멘트 입력 */} + <div className="grid gap-2"> + <Label htmlFor="comments">메시지</Label> + <Textarea + id="comments" + placeholder="추가 메시지를 입력하세요 (선택사항)" + value={comments} + onChange={(e) => setComments(e.target.value)} + rows={4} + /> + </div> + + {/* 첨부 파일 목록 */} + <div className="grid gap-2"> + <Label>첨부 파일 ({selectedDocuments.length}개)</Label> + <ScrollArea className="h-[200px] w-full rounded-md border p-4"> + <div className="space-y-2"> + {selectedDocuments.map((doc, index) => ( + <div key={doc.documentReviewId} className="flex items-center gap-2 p-2 rounded-md bg-muted/50"> + <FileText className="h-4 w-4 text-muted-foreground" /> + <div className="flex-1 min-w-0"> + <p className="text-sm font-medium truncate">{doc.documentName}</p> + <div className="flex items-center gap-2 text-xs text-muted-foreground"> + <span>{doc.documentType}</span> + <span>•</span> + <Badge variant={doc.documentSource === "buyer" ? "default" : "secondary"} className="text-xs"> + {doc.documentSource} + </Badge> + {doc.reviewStatus && ( + <> + <span>•</span> + <span>{doc.reviewStatus}</span> + </> + )} + </div> + </div> + </div> + ))} + </div> + </ScrollArea> + </div> + + {/* 경고 메시지 */} + {selectedDocuments.some(doc => doc.reviewStatus === "반려") && ( + <div className="flex items-start gap-2 p-3 rounded-md bg-destructive/10 text-destructive"> + <AlertCircle className="h-4 w-4 mt-0.5" /> + <p className="text-sm"> + 반려된 문서가 포함되어 있습니다. 계속 진행하시겠습니까? + </p> + </div> + )} + </div> + + <DialogFooter> + <Button + variant="outline" + onClick={() => onOpenChange(false)} + disabled={isLoading} + > + 취소 + </Button> + <Button + onClick={handleSendEmail} + disabled={isLoading || recipients.length === 0} + > + {isLoading ? ( + <> + <Loader2 className="mr-2 h-4 w-4 animate-spin" /> + 전송 중... + </> + ) : ( + <> + <Mail className="mr-2 h-4 w-4" /> + 이메일 전송 + </> + )} + </Button> + </DialogFooter> + </DialogContent> + </Dialog> + ) +}
\ No newline at end of file |
