summaryrefslogtreecommitdiff
path: root/lib
diff options
context:
space:
mode:
Diffstat (limited to 'lib')
-rw-r--r--lib/bidding/detail/table/bidding-detail-vendor-toolbar-actions.tsx88
-rw-r--r--lib/bidding/detail/table/bidding-invitation-dialog.tsx692
-rw-r--r--lib/bidding/list/biddings-table-toolbar-actions.tsx9
-rw-r--r--lib/bidding/list/biddings-table.tsx25
-rw-r--r--lib/bidding/list/create-bidding-dialog.tsx78
-rw-r--r--lib/bidding/pre-quote/service.ts383
-rw-r--r--lib/bidding/pre-quote/table/bidding-pre-quote-invitation-dialog.tsx774
-rw-r--r--lib/bidding/pre-quote/table/bidding-pre-quote-vendor-table.tsx2
-rw-r--r--lib/bidding/pre-quote/table/bidding-pre-quote-vendor-toolbar-actions.tsx3
-rw-r--r--lib/bidding/service.ts87
10 files changed, 1992 insertions, 149 deletions
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 484b1b1e..0b707944 100644
--- a/lib/bidding/detail/table/bidding-detail-vendor-toolbar-actions.tsx
+++ b/lib/bidding/detail/table/bidding-detail-vendor-toolbar-actions.tsx
@@ -7,11 +7,14 @@ import { useTransition } from "react"
import { Button } from "@/components/ui/button"
import { Plus, Send, RotateCcw, XCircle, Trophy, FileText, DollarSign } from "lucide-react"
import { QuotationVendor, registerBidding, markAsDisposal, createRebidding, awardBidding } from "@/lib/bidding/detail/service"
+import { sendBiddingBasicContracts, getSelectedVendorsForBidding } from "@/lib/bidding/pre-quote/service"
+
import { BiddingDetailVendorCreateDialog } from "./bidding-detail-vendor-create-dialog"
import { BiddingDocumentUploadDialog } from "./bidding-document-upload-dialog"
import { BiddingVendorPricesDialog } from "./bidding-vendor-prices-dialog"
import { Bidding } from "@/db/schema"
import { useToast } from "@/hooks/use-toast"
+import { BiddingInvitationDialog } from "./bidding-invitation-dialog"
interface BiddingDetailVendorToolbarActionsProps {
table: Table<QuotationVendor>
@@ -40,6 +43,17 @@ export function BiddingDetailVendorToolbarActions({
const [isCreateDialogOpen, setIsCreateDialogOpen] = React.useState(false)
const [isDocumentDialogOpen, setIsDocumentDialogOpen] = React.useState(false)
const [isPricesDialogOpen, setIsPricesDialogOpen] = React.useState(false)
+ const [isBiddingInvitationDialogOpen, setIsBiddingInvitationDialogOpen] = React.useState(false)
+ const [selectedVendors, setSelectedVendors] = React.useState<any[]>([])
+
+ // 본입찰 초대 다이얼로그가 열릴 때 선정된 업체들 조회
+ React.useEffect(() => {
+ if (isBiddingInvitationDialogOpen) {
+ getSelectedVendors().then(vendors => {
+ setSelectedVendors(vendors)
+ })
+ }
+ }, [isBiddingInvitationDialogOpen, biddingId])
const handleCreateVendor = () => {
setIsCreateDialogOpen(true)
@@ -54,23 +68,71 @@ export function BiddingDetailVendorToolbarActions({
}
const handleRegister = () => {
- startTransition(async () => {
- const result = await registerBidding(bidding.id, userId)
+ // 본입찰 초대 다이얼로그 열기
+ setIsBiddingInvitationDialogOpen(true)
+ }
- if (result.success) {
+ const handleBiddingInvitationSend = async (data: any) => {
+ try {
+ // 1. 기본계약 발송
+ const contractResult = await sendBiddingBasicContracts(
+ biddingId,
+ data.vendors,
+ data.generatedPdfs,
+ data.message
+ )
+
+ if (!contractResult.success) {
toast({
- title: result.message,
- description: result.message,
+ title: '기본계약 발송 실패',
+ description: contractResult.error,
+ variant: 'destructive',
+ })
+ return
+ }
+
+ // 2. 입찰 등록 진행
+ const registerResult = await registerBidding(bidding.id, userId)
+
+ if (registerResult.success) {
+ toast({
+ title: '본입찰 초대 완료',
+ description: '기본계약 발송 및 본입찰 초대가 완료되었습니다.',
})
+ setIsBiddingInvitationDialogOpen(false)
router.refresh()
+ onSuccess()
} else {
toast({
- title: result.error,
- description: result.error,
+ title: '오류',
+ description: registerResult.error,
variant: 'destructive',
})
}
- })
+ } catch (error) {
+ console.error('본입찰 초대 실패:', error)
+ toast({
+ title: '오류',
+ description: '본입찰 초대에 실패했습니다.',
+ variant: 'destructive',
+ })
+ }
+ }
+
+ // 선정된 업체들 조회 (서버 액션 함수 사용)
+ const getSelectedVendors = async () => {
+ try {
+ const result = await getSelectedVendorsForBidding(biddingId)
+ if (result.success) {
+ return result.vendors
+ } else {
+ console.error('선정된 업체 조회 실패:', result.error)
+ return []
+ }
+ } catch (error) {
+ console.error('선정된 업체 조회 실패:', error)
+ return []
+ }
}
const handleMarkAsDisposal = () => {
@@ -234,6 +296,16 @@ export function BiddingDetailVendorToolbarActions({
targetPrice={bidding.targetPrice ? parseFloat(bidding.targetPrice.toString()) : null}
currency={bidding.currency}
/>
+
+ <BiddingInvitationDialog
+ open={isBiddingInvitationDialogOpen}
+ onOpenChange={setIsBiddingInvitationDialogOpen}
+ vendors={selectedVendors}
+ biddingId={biddingId}
+ biddingTitle={bidding.title || ''}
+ projectName={bidding.projectName}
+ onSend={handleBiddingInvitationSend}
+ />
</>
)
}
diff --git a/lib/bidding/detail/table/bidding-invitation-dialog.tsx b/lib/bidding/detail/table/bidding-invitation-dialog.tsx
new file mode 100644
index 00000000..031231a1
--- /dev/null
+++ b/lib/bidding/detail/table/bidding-invitation-dialog.tsx
@@ -0,0 +1,692 @@
+'use client'
+
+import * as React from 'react'
+import { Button } from '@/components/ui/button'
+import { Checkbox } from '@/components/ui/checkbox'
+import { Input } from '@/components/ui/input'
+import { Label } from '@/components/ui/label'
+import {
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogFooter,
+ DialogHeader,
+ DialogTitle,
+} from '@/components/ui/dialog'
+import { Badge } from '@/components/ui/badge'
+import { Separator } from '@/components/ui/separator'
+import { Progress } from '@/components/ui/progress'
+import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'
+import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
+import { cn } from '@/lib/utils'
+import {
+ Mail,
+ Building2,
+ Calendar,
+ FileText,
+ CheckCircle,
+ Info,
+ RefreshCw,
+ Plus,
+ X
+} from 'lucide-react'
+import { sendBiddingBasicContracts, getSelectedVendorsForBidding, getExistingBasicContractsForBidding } from '../../pre-quote/service'
+import { getActiveContractTemplates } from '../../service'
+import { useToast } from '@/hooks/use-toast'
+import { useTransition } from 'react'
+
+interface VendorContractRequirement {
+ vendorId: number
+ vendorName: string
+ vendorCode?: string
+ vendorCountry?: string
+ contactPerson?: string
+ contactEmail?: string
+ ndaYn?: boolean
+ generalGtcYn?: boolean
+ projectGtcYn?: boolean
+ agreementYn?: boolean
+ biddingCompanyId: number
+ biddingId: number
+}
+
+interface BasicContractTemplate {
+ id: number
+ templateName: string
+ revision: number
+ status: string
+ filePath: string | null
+ validityPeriod: number | null
+ legalReviewRequired: boolean
+ createdAt: Date | null
+}
+
+interface SelectedContract {
+ templateId: number
+ templateName: string
+ contractType: string
+ checked: boolean
+}
+
+interface BiddingInvitationDialogProps {
+ open: boolean
+ onOpenChange: (open: boolean) => void
+ vendors: VendorContractRequirement[]
+ biddingId: number
+ biddingTitle: string
+ projectName?: string
+ onSend: (data: {
+ vendors: Array<{
+ vendorId: number
+ vendorName: string
+ vendorCode?: string
+ vendorCountry?: string
+ selectedMainEmail: string
+ additionalEmails: string[]
+ contractRequirements: {
+ ndaYn: boolean
+ generalGtcYn: boolean
+ projectGtcYn: boolean
+ agreementYn: boolean
+ }
+ biddingCompanyId: number
+ biddingId: number
+ hasExistingContracts?: boolean
+ }>
+ generatedPdfs: Array<{
+ key: string
+ buffer: number[]
+ fileName: string
+ }>
+ message?: string
+ }) => Promise<void>
+}
+
+export function BiddingInvitationDialog({
+ open,
+ onOpenChange,
+ vendors,
+ biddingId,
+ biddingTitle,
+ projectName,
+ onSend,
+}: BiddingInvitationDialogProps) {
+ const { toast } = useToast()
+ const [isPending, startTransition] = useTransition()
+
+ // 기본계약 관련 상태
+ const [existingContracts, setExistingContracts] = React.useState<any[]>([])
+ const [isGeneratingPdfs, setIsGeneratingPdfs] = React.useState(false)
+ const [pdfGenerationProgress, setPdfGenerationProgress] = React.useState(0)
+ const [currentGeneratingContract, setCurrentGeneratingContract] = React.useState('')
+
+ // 기본계약서 템플릿 관련 상태
+ const [availableTemplates, setAvailableTemplates] = React.useState<any[]>([])
+ const [selectedContracts, setSelectedContracts] = React.useState<SelectedContract[]>([])
+ const [isLoadingTemplates, setIsLoadingTemplates] = React.useState(false)
+ const [additionalMessage, setAdditionalMessage] = React.useState('')
+
+ // 선택된 업체들 (사전견적에서 선정된 업체들만)
+ const selectedVendors = React.useMemo(() =>
+ vendors.filter(vendor => vendor.ndaYn || vendor.generalGtcYn || vendor.projectGtcYn || vendor.agreementYn),
+ [vendors]
+ )
+
+ // 기존 계약이 있는 업체들과 없는 업체들 분리
+ const vendorsWithExistingContracts = React.useMemo(() =>
+ selectedVendors.filter(vendor =>
+ existingContracts.some((ec: any) =>
+ ec.vendorId === vendor.vendorId && ec.biddingCompanyId === vendor.biddingCompanyId
+ )
+ ),
+ [selectedVendors, existingContracts]
+ )
+
+ const vendorsWithoutExistingContracts = React.useMemo(() =>
+ selectedVendors.filter(vendor =>
+ !existingContracts.some((ec: any) =>
+ ec.vendorId === vendor.vendorId && ec.biddingCompanyId === vendor.biddingCompanyId
+ )
+ ),
+ [selectedVendors, existingContracts]
+ )
+
+ // 다이얼로그가 열릴 때 기존 계약 조회 및 템플릿 로드
+ React.useEffect(() => {
+ if (open) {
+ const fetchInitialData = async () => {
+ setIsLoadingTemplates(true);
+ try {
+ const [contractsResult, templatesData] = await Promise.all([
+ getSelectedVendorsForBidding(biddingId),
+ getActiveContractTemplates()
+ ]);
+
+ // 기존 계약 조회 (사전견적에서 보낸 기본계약 확인) - 서버 액션 사용
+ const existingContracts = await getExistingBasicContractsForBidding(biddingId);
+ setExistingContracts(existingContracts.success ? existingContracts.contracts || [] : []);
+
+ // 템플릿 로드 (4개 타입만 필터링)
+ // 4개 템플릿 타입만 필터링: 비밀, General, Project, 기술자료
+ const allowedTemplateNames = ['비밀', 'General GTC', '기술', '기술자료'];
+ const rawTemplates = templatesData.templates || [];
+ const filteredTemplates = rawTemplates.filter((template: any) =>
+ allowedTemplateNames.some(allowedName =>
+ template.templateName.includes(allowedName) ||
+ allowedName.includes(template.templateName)
+ )
+ );
+ setAvailableTemplates(filteredTemplates as any);
+ const initialSelected = filteredTemplates.map((template: any) => ({
+ templateId: template.id,
+ templateName: template.templateName,
+ contractType: template.templateName,
+ checked: false
+ }));
+ setSelectedContracts(initialSelected);
+
+ } catch (error) {
+ console.error('초기 데이터 로드 실패:', error);
+ toast({
+ title: '오류',
+ description: '기본 정보를 불러오는 데 실패했습니다.',
+ variant: 'destructive',
+ });
+ setAvailableTemplates([]);
+ setSelectedContracts([]);
+ } finally {
+ setIsLoadingTemplates(false);
+ }
+ }
+ fetchInitialData();
+ }
+ }, [open, biddingId, toast]);
+
+ const handleOpenChange = (open: boolean) => {
+ onOpenChange(open)
+ if (!open) {
+ setSelectedContracts([])
+ setAdditionalMessage('')
+ setIsGeneratingPdfs(false)
+ setPdfGenerationProgress(0)
+ setCurrentGeneratingContract('')
+ }
+ }
+
+ // 기본계약서 선택 토글
+ const toggleContractSelection = (templateId: number) => {
+ setSelectedContracts(prev =>
+ prev.map(contract =>
+ contract.templateId === templateId
+ ? { ...contract, checked: !contract.checked }
+ : contract
+ )
+ )
+ }
+
+ // 모든 기본계약서 선택/해제
+ const toggleAllContractSelection = (checked: boolean | 'indeterminate') => {
+ setSelectedContracts(prev =>
+ prev.map(contract => ({ ...contract, checked: !!checked }))
+ )
+ }
+
+ // PDF 생성 유틸리티 함수
+ const generateBasicContractPdf = async (
+ template: BasicContractTemplate,
+ vendorId: number
+ ): Promise<{ buffer: number[]; fileName: string }> => {
+ try {
+ // 1. 템플릿 데이터 준비 (서버 API 호출)
+ const prepareResponse = await fetch("/api/contracts/prepare-template", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({
+ templateName: template.templateName,
+ vendorId,
+ }),
+ });
+
+ if (!prepareResponse.ok) {
+ throw new Error("템플릿 준비 실패");
+ }
+
+ const { template: preparedTemplate, templateData } = await prepareResponse.json();
+
+ // 2. 템플릿 파일 다운로드
+ const templateResponse = await fetch("/api/contracts/get-template", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ templatePath: preparedTemplate.filePath }),
+ });
+
+ const templateBlob = await templateResponse.blob();
+ const templateFile = new window.File([templateBlob], "template.docx", {
+ type: "application/vnd.openxmlformats-officedocument.wordprocessingml.document"
+ });
+
+ // 3. PDFTron WebViewer로 PDF 변환
+ const { default: WebViewer } = await import("@pdftron/webviewer");
+
+ const tempDiv = document.createElement('div');
+ tempDiv.style.display = 'none';
+ document.body.appendChild(tempDiv);
+
+ try {
+ const instance = await WebViewer(
+ {
+ path: "/pdftronWeb",
+ licenseKey: process.env.NEXT_PUBLIC_PDFTRON_WEBVIEW_KEY,
+ fullAPI: true,
+ },
+ tempDiv
+ );
+
+ const { Core } = instance;
+ const { createDocument } = Core;
+
+ const templateDoc = await createDocument(templateFile, {
+ filename: templateFile.name,
+ extension: 'docx',
+ });
+
+ // 변수 치환 적용
+ await templateDoc.applyTemplateValues(templateData);
+
+ // PDF 변환
+ const fileData = await templateDoc.getFileData();
+ const pdfBuffer = await Core.officeToPDFBuffer(fileData, { extension: 'docx' });
+
+ const fileName = `${template.templateName}_${Date.now()}.pdf`;
+
+ return {
+ buffer: Array.from(pdfBuffer), // Uint8Array를 일반 배열로 변환
+ fileName
+ };
+
+ } finally {
+ if (tempDiv.parentNode) {
+ document.body.removeChild(tempDiv);
+ }
+ }
+ } catch (error) {
+ console.error(`기본계약 PDF 생성 실패 (${template.templateName}):`, error);
+ throw error;
+ }
+ };
+
+ const handleSendInvitation = () => {
+ const selectedContractTemplates = selectedContracts.filter(c => c.checked);
+
+ if (selectedContractTemplates.length === 0) {
+ toast({
+ title: '알림',
+ description: '발송할 기본계약서를 선택해주세요.',
+ variant: 'default',
+ })
+ return
+ }
+
+ startTransition(async () => {
+ try {
+ // 선택된 템플릿에 따라 PDF 생성
+ setIsGeneratingPdfs(true)
+ setPdfGenerationProgress(0)
+
+ const generatedPdfsMap = new Map<string, { buffer: number[], fileName: string }>()
+
+ let generatedCount = 0;
+ for (const vendor of selectedVendors) {
+ // 사전견적에서 이미 기본계약을 보낸 벤더인지 확인
+ const hasExistingContract = existingContracts.some((ec: any) =>
+ ec.vendorId === vendor.vendorId && ec.biddingCompanyId === vendor.biddingCompanyId
+ );
+
+ if (hasExistingContract) {
+ console.log(`벤더 ${vendor.vendorName}는 사전견적에서 이미 기본계약을 받았으므로 건너뜁니다.`);
+ generatedCount++;
+ setPdfGenerationProgress((generatedCount / selectedVendors.length) * 100);
+ continue;
+ }
+
+ for (const contract of selectedContractTemplates) {
+ setCurrentGeneratingContract(`${vendor.vendorName} - ${contract.templateName}`);
+ const templateDetails = availableTemplates.find(t => t.id === contract.templateId);
+
+ if (templateDetails) {
+ const pdfData = await generateBasicContractPdf(templateDetails, vendor.vendorId);
+ // sendBiddingBasicContracts와 동일한 키 형식 사용
+ let contractType = '';
+ if (contract.templateName.includes('비밀')) {
+ contractType = 'NDA';
+ } else if (contract.templateName.includes('General GTC')) {
+ contractType = 'General_GTC';
+ } else if (contract.templateName.includes('기술') && !contract.templateName.includes('기술자료')) {
+ contractType = 'Project_GTC';
+ } else if (contract.templateName.includes('기술자료')) {
+ contractType = '기술자료';
+ }
+ const key = `${vendor.vendorId}_${contractType}_${contract.templateName}`;
+ generatedPdfsMap.set(key, pdfData);
+ }
+ }
+ generatedCount++;
+ setPdfGenerationProgress((generatedCount / selectedVendors.length) * 100);
+ }
+
+ setIsGeneratingPdfs(false);
+
+ const vendorData = selectedVendors.map(vendor => {
+ const hasExistingContract = existingContracts.some((ec: any) =>
+ ec.vendorId === vendor.vendorId && ec.biddingCompanyId === vendor.biddingCompanyId
+ );
+
+ return {
+ vendorId: vendor.vendorId,
+ vendorName: vendor.vendorName,
+ vendorCode: vendor.vendorCode,
+ vendorCountry: vendor.vendorCountry,
+ selectedMainEmail: vendor.contactEmail || '',
+ additionalEmails: [],
+ contractRequirements: {
+ ndaYn: vendor.ndaYn || false,
+ generalGtcYn: vendor.generalGtcYn || false,
+ projectGtcYn: vendor.projectGtcYn || false,
+ agreementYn: vendor.agreementYn || false
+ },
+ biddingCompanyId: vendor.biddingCompanyId,
+ biddingId: vendor.biddingId,
+ hasExistingContracts: hasExistingContract
+ };
+ });
+
+ const pdfsArray = Array.from(generatedPdfsMap.entries()).map(([key, data]) => ({
+ key,
+ buffer: data.buffer,
+ fileName: data.fileName,
+ }));
+
+ await onSend({
+ vendors: vendorData,
+ generatedPdfs: pdfsArray,
+ message: additionalMessage
+ });
+
+ } catch (error) {
+ console.error('본입찰 초대 실패:', error);
+ toast({
+ title: '오류',
+ description: '본입찰 초대 중 오류가 발생했습니다.',
+ variant: 'destructive',
+ });
+ setIsGeneratingPdfs(false);
+ }
+ })
+ }
+
+ const selectedContractCount = selectedContracts.filter(c => c.checked).length;
+
+ return (
+ <Dialog open={open} onOpenChange={handleOpenChange}>
+ <DialogContent className="sm:max-w-[700px] max-h-[90vh] flex flex-col">
+ <DialogHeader>
+ <DialogTitle className="flex items-center gap-2">
+ <Mail className="w-5 h-5" />
+ 본입찰 초대
+ </DialogTitle>
+ <DialogDescription>
+ {biddingTitle} - 선정된 {selectedVendors.length}개 업체에 본입찰 초대와 기본계약서를 발송합니다.
+ </DialogDescription>
+ </DialogHeader>
+
+ <div className="flex-1 overflow-y-auto px-1" style={{ maxHeight: 'calc(70vh - 200px)' }}>
+ <div className="space-y-6 pr-4">
+ {/* 기존 계약 정보 */}
+ {vendorsWithExistingContracts.length > 0 && (
+ <Alert className="border-orange-500 bg-orange-50">
+ <Info className="h-4 w-4 text-orange-600" />
+ <AlertTitle className="text-orange-800">기존 계약 정보</AlertTitle>
+ <AlertDescription className="text-orange-700">
+ 사전견적에서 이미 기본계약을 받은 업체가 있습니다.
+ 해당 업체들은 계약서 재생성을 건너뜁니다.
+ </AlertDescription>
+ </Alert>
+ )}
+
+ {/* 대상 업체 정보 */}
+ <Card>
+ <CardHeader className="pb-3">
+ <CardTitle className="flex items-center gap-2 text-base">
+ <Building2 className="h-5 w-5 text-green-600" />
+ 초대 대상 업체 ({selectedVendors.length}개)
+ </CardTitle>
+ </CardHeader>
+ <CardContent>
+ {selectedVendors.length === 0 ? (
+ <div className="text-center py-6 text-muted-foreground">
+ 초대 가능한 업체가 없습니다.
+ </div>
+ ) : (
+ <div className="space-y-4">
+ {/* 계약서가 생성될 업체들 */}
+ {vendorsWithoutExistingContracts.length > 0 && (
+ <div>
+ <h4 className="text-sm font-medium text-green-700 mb-2 flex items-center gap-2">
+ <CheckCircle className="h-4 w-4 text-green-600" />
+ 계약서 생성 대상 ({vendorsWithoutExistingContracts.length}개)
+ </h4>
+ <div className="space-y-2 max-h-32 overflow-y-auto">
+ {vendorsWithoutExistingContracts.map((vendor) => (
+ <div key={vendor.vendorId} className="flex items-center gap-2 text-sm p-2 bg-green-50 rounded border border-green-200">
+ <CheckCircle className="h-4 w-4 text-green-600" />
+ <span className="font-medium">{vendor.vendorName}</span>
+ <Badge variant="outline" className="text-xs">
+ {vendor.vendorCode}
+ </Badge>
+ </div>
+ ))}
+ </div>
+ </div>
+ )}
+
+ {/* 기존 계약이 있는 업체들 */}
+ {vendorsWithExistingContracts.length > 0 && (
+ <div>
+ <h4 className="text-sm font-medium text-orange-700 mb-2 flex items-center gap-2">
+ <X className="h-4 w-4 text-orange-600" />
+ 기존 계약 존재 (건너뜀) ({vendorsWithExistingContracts.length}개)
+ </h4>
+ <div className="space-y-2 max-h-32 overflow-y-auto">
+ {vendorsWithExistingContracts.map((vendor) => (
+ <div key={vendor.vendorId} className="flex items-center gap-2 text-sm p-2 bg-orange-50 rounded border border-orange-200 opacity-75">
+ <X className="h-4 w-4 text-orange-600" />
+ <span className="text-muted-foreground">{vendor.vendorName}</span>
+ <Badge variant="outline" className="text-xs">
+ {vendor.vendorCode}
+ </Badge>
+ <Badge variant="secondary" className="text-xs">
+ 계약 존재
+ </Badge>
+ </div>
+ ))}
+ </div>
+ </div>
+ )}
+ </div>
+ )}
+ </CardContent>
+ </Card>
+
+ {/* 기본계약서 선택 */}
+ <Card>
+ <CardHeader className="pb-3">
+ <CardTitle className="flex items-center gap-2 text-base">
+ <FileText className="h-5 w-5 text-blue-600" />
+ 기본계약 선택
+ </CardTitle>
+ </CardHeader>
+ <CardContent className="space-y-4">
+ {/* 템플릿 로딩 */}
+ {isLoadingTemplates ? (
+ <div className="text-center py-6">
+ <RefreshCw className="h-6 w-6 animate-spin mx-auto mb-2 text-blue-600" />
+ <p className="text-sm text-muted-foreground">기본계약서 템플릿을 불러오는 중...</p>
+ </div>
+ ) : (
+ <div className="space-y-4">
+ {availableTemplates.length === 0 ? (
+ <div className="text-center py-8 text-muted-foreground">
+ <FileText className="h-12 w-12 mx-auto mb-4 opacity-50" />
+ <p>사용 가능한 기본계약서 템플릿이 없습니다.</p>
+ </div>
+ ) : (
+ <>
+ <div className="flex items-center justify-between p-3 bg-muted/50 rounded-lg">
+ <div className="flex items-center gap-2">
+ <Checkbox
+ id="select-all-contracts"
+ checked={selectedContracts.length > 0 && selectedContracts.every(c => c.checked)}
+ onCheckedChange={toggleAllContractSelection}
+ />
+ <Label htmlFor="select-all-contracts" className="font-medium">
+ 전체 선택 ({availableTemplates.length}개 템플릿)
+ </Label>
+ </div>
+ <Badge variant="outline">
+ {selectedContractCount}개 선택됨
+ </Badge>
+ </div>
+ <div className="grid gap-3 max-h-60 overflow-y-auto">
+ {selectedContracts.map((contract) => (
+ <div
+ key={contract.templateId}
+ className={cn(
+ "flex items-center justify-between p-3 border rounded-lg hover:bg-muted/50 transition-colors cursor-pointer",
+ contract.checked && "border-blue-500 bg-blue-50"
+ )}
+ onClick={() => toggleContractSelection(contract.templateId)}
+ >
+ <div className="flex items-center gap-3">
+ <Checkbox
+ id={`contract-${contract.templateId}`}
+ checked={contract.checked}
+ onCheckedChange={() => toggleContractSelection(contract.templateId)}
+ />
+ <div className="flex-1">
+ <Label
+ htmlFor={`contract-${contract.templateId}`}
+ className="font-medium cursor-pointer"
+ >
+ {contract.templateName}
+ </Label>
+ <p className="text-xs text-muted-foreground mt-1">
+ {contract.contractType}
+ </p>
+ </div>
+ </div>
+ </div>
+ ))}
+ </div>
+ </>
+ )}
+
+ {/* 선택된 템플릿 요약 */}
+ {selectedContractCount > 0 && (
+ <div className="mt-4 p-3 bg-green-50 border border-green-200 rounded-lg">
+ <div className="flex items-center gap-2 mb-2">
+ <CheckCircle className="h-4 w-4 text-green-600" />
+ <span className="font-medium text-green-900 text-sm">
+ 선택된 기본계약서 ({selectedContractCount}개)
+ </span>
+ </div>
+ <ul className="space-y-1 text-xs text-green-800 list-disc list-inside">
+ {selectedContracts.filter(c => c.checked).map((contract) => (
+ <li key={contract.templateId}>
+ {contract.templateName}
+ </li>
+ ))}
+ </ul>
+ </div>
+ )}
+ </div>
+ )}
+ </CardContent>
+ </Card>
+
+ {/* 추가 메시지 */}
+ <div className="space-y-2">
+ <Label htmlFor="invitationMessage" className="text-sm font-medium">
+ 초대 메시지 (선택사항)
+ </Label>
+ <textarea
+ id="invitationMessage"
+ className="w-full min-h-[60px] p-3 text-sm border rounded-lg resize-none focus:outline-none focus:ring-2 focus:ring-primary"
+ placeholder="업체에 전달할 추가 메시지를 입력하세요..."
+ value={additionalMessage}
+ onChange={(e) => setAdditionalMessage(e.target.value)}
+ />
+ </div>
+
+ {/* PDF 생성 진행 상황 */}
+ {isGeneratingPdfs && (
+ <Alert className="border-blue-500 bg-blue-50">
+ <div className="space-y-3">
+ <div className="flex items-center gap-2">
+ <RefreshCw className="h-4 w-4 animate-spin text-blue-600" />
+ <AlertTitle className="text-blue-800">기본계약서 생성 중</AlertTitle>
+ </div>
+ <AlertDescription>
+ <div className="space-y-2">
+ <p className="text-sm text-blue-700">{currentGeneratingContract}</p>
+ <Progress value={pdfGenerationProgress} className="h-2" />
+ <p className="text-xs text-blue-600">
+ {Math.round(pdfGenerationProgress)}% 완료
+ </p>
+ </div>
+ </AlertDescription>
+ </div>
+ </Alert>
+ )}
+ </div>
+ </div>
+
+ <DialogFooter className="flex-col sm:flex-row-reverse sm:justify-between items-center px-4 pt-4">
+ <div className="flex gap-2 w-full sm:w-auto">
+ <Button variant="outline" onClick={() => handleOpenChange(false)} className="w-full sm:w-auto">
+ 취소
+ </Button>
+ <Button
+ onClick={handleSendInvitation}
+ disabled={isPending || selectedContractCount === 0 || isGeneratingPdfs}
+ className="w-full sm:w-auto"
+ >
+ {isGeneratingPdfs ? (
+ <>
+ <RefreshCw className="w-4 h-4 mr-2 animate-spin" />
+ 계약서 생성중... ({Math.round(pdfGenerationProgress)}%)
+ </>
+ ) : isPending ? (
+ <>
+ <RefreshCw className="w-4 h-4 mr-2 animate-spin" />
+ 발송 중...
+ </>
+ ) : (
+ <>
+ <Mail className="w-4 h-4 mr-2" />
+ 본입찰 초대 발송
+ </>
+ )}
+ </Button>
+ </div>
+ {/* {(selectedContractCount > 0) && (
+ <div className="mt-4 sm:mt-0 text-sm text-muted-foreground">
+ <p>
+ {selectedVendors.length}개 업체에 <strong>{selectedContractCount}개</strong>의 기본계약서를 발송합니다.
+ </p>
+ </div>
+ )} */}
+ </DialogFooter>
+ </DialogContent>
+ </Dialog>
+ )
+}
diff --git a/lib/bidding/list/biddings-table-toolbar-actions.tsx b/lib/bidding/list/biddings-table-toolbar-actions.tsx
index 70b48a36..2b7a9d7d 100644
--- a/lib/bidding/list/biddings-table-toolbar-actions.tsx
+++ b/lib/bidding/list/biddings-table-toolbar-actions.tsx
@@ -22,9 +22,11 @@ import { CreateBiddingDialog } from "./create-bidding-dialog"
interface BiddingsTableToolbarActionsProps {
table: Table<BiddingListItem>
+ paymentTermsOptions: Array<{code: string, description: string}>
+ incotermsOptions: Array<{code: string, description: string}>
}
-export function BiddingsTableToolbarActions({ table }: BiddingsTableToolbarActionsProps) {
+export function BiddingsTableToolbarActions({ table, paymentTermsOptions, incotermsOptions }: BiddingsTableToolbarActionsProps) {
const router = useRouter()
const [isExporting, setIsExporting] = React.useState(false)
@@ -54,7 +56,10 @@ export function BiddingsTableToolbarActions({ table }: BiddingsTableToolbarActio
return (
<div className="flex items-center gap-2">
{/* 신규 생성 */}
- <CreateBiddingDialog/>
+ <CreateBiddingDialog
+ paymentTermsOptions={paymentTermsOptions}
+ incotermsOptions={incotermsOptions}
+ />
{/* 개찰 (입찰 오픈) */}
{/* {openEligibleBiddings.length > 0 && (
diff --git a/lib/bidding/list/biddings-table.tsx b/lib/bidding/list/biddings-table.tsx
index 3b60c69b..2a8f98c3 100644
--- a/lib/bidding/list/biddings-table.tsx
+++ b/lib/bidding/list/biddings-table.tsx
@@ -12,7 +12,7 @@ import { useDataTable } from "@/hooks/use-data-table"
import { DataTable } from "@/components/data-table/data-table"
import { DataTableAdvancedToolbar } from "@/components/data-table/data-table-advanced-toolbar"
import { getBiddingsColumns } from "./biddings-table-columns"
-import { getBiddings, getBiddingStatusCounts } from "@/lib/bidding/service"
+import { getBiddings, getBiddingStatusCounts, getActivePaymentTerms, getActiveIncoterms, getBiddingTypeCounts, getBiddingManagerCounts, getBiddingMonthlyStats } from "@/lib/bidding/service"
import { BiddingListItem } from "@/db/schema"
import { BiddingsTableToolbarActions } from "./biddings-table-toolbar-actions"
import {
@@ -28,13 +28,26 @@ interface BiddingsTableProps {
promises: Promise<
[
Awaited<ReturnType<typeof getBiddings>>,
- Awaited<ReturnType<typeof getBiddingStatusCounts>>
+ Awaited<ReturnType<typeof getBiddingStatusCounts>>,
+ Awaited<ReturnType<typeof getBiddingTypeCounts>>, // 추가
+ Awaited<ReturnType<typeof getBiddingManagerCounts>>, // 추가
+ Awaited<ReturnType<typeof getBiddingMonthlyStats>>, // 추가
+ Awaited<ReturnType<typeof getActivePaymentTerms>>,
+ Awaited<ReturnType<typeof getActiveIncoterms>>
]
>
}
export function BiddingsTable({ promises }: BiddingsTableProps) {
- const [{ data, pageCount }, statusCounts] = React.use(promises)
+ const [biddingsResult, statusCounts, typeCounts, managerCounts, monthlyStats, paymentTermsResult, incotermsResult] = React.use(promises)
+
+ // biddingsResult에서 data와 pageCount 추출
+ const { data, pageCount } = biddingsResult
+
+ const paymentTermsOptions = paymentTermsResult.success && 'data' in paymentTermsResult ? paymentTermsResult.data || [] : []
+ const incotermsOptions = incotermsResult.success && 'data' in incotermsResult ? incotermsResult.data || [] : []
+ console.log(paymentTermsOptions,"paymentTermsOptions")
+ console.log(incotermsOptions,"incotermsOptions")
const [isCompact, setIsCompact] = React.useState<boolean>(false)
const [specMeetingDialogOpen, setSpecMeetingDialogOpen] = React.useState(false)
const [prDocumentsDialogOpen, setPrDocumentsDialogOpen] = React.useState(false)
@@ -164,7 +177,11 @@ export function BiddingsTable({ promises }: BiddingsTableProps) {
compactStorageKey="biddingsTableCompact"
onCompactChange={handleCompactChange}
>
- <BiddingsTableToolbarActions table={table} />
+ <BiddingsTableToolbarActions
+ table={table}
+ paymentTermsOptions={paymentTermsOptions}
+ incotermsOptions={incotermsOptions}
+ />
</DataTableAdvancedToolbar>
</DataTable>
diff --git a/lib/bidding/list/create-bidding-dialog.tsx b/lib/bidding/list/create-bidding-dialog.tsx
index 57cc1002..4fc4fd7b 100644
--- a/lib/bidding/list/create-bidding-dialog.tsx
+++ b/lib/bidding/list/create-bidding-dialog.tsx
@@ -67,7 +67,7 @@ import {
} from "@/components/ui/file-list"
import { Checkbox } from "@/components/ui/checkbox"
-import { createBidding, type CreateBiddingInput } from "@/lib/bidding/service"
+import { createBidding, type CreateBiddingInput, getActivePaymentTerms, getActiveIncoterms } from "@/lib/bidding/service"
import {
createBiddingSchema,
type CreateBiddingSchema
@@ -116,6 +116,8 @@ interface PRItemInfo {
quantityUnit: string
totalWeight: string
weightUnit: string
+ materialDescription: string
+ hasSpecDocument: boolean
requestedDeliveryDate: string
specFiles: File[]
isRepresentative: boolean // 대표 아이템 여부
@@ -125,7 +127,12 @@ interface PRItemInfo {
const TAB_ORDER = ["basic", "contract", "schedule", "conditions", "details", "manager"] as const
type TabType = typeof TAB_ORDER[number]
-export function CreateBiddingDialog() {
+interface CreateBiddingDialogProps {
+ paymentTermsOptions?: Array<{code: string, description: string}>
+ incotermsOptions?: Array<{code: string, description: string}>
+}
+
+export function CreateBiddingDialog({ paymentTermsOptions = [], incotermsOptions = [] }: CreateBiddingDialogProps) {
const router = useRouter()
const [isSubmitting, setIsSubmitting] = React.useState(false)
const { data: session } = useSession()
@@ -168,6 +175,7 @@ export function CreateBiddingDialog() {
sparePartOptions: "",
})
+
// 사양설명회 파일 추가
const addMeetingFiles = (files: File[]) => {
setSpecMeetingInfo(prev => ({
@@ -311,6 +319,8 @@ export function CreateBiddingDialog() {
form.setValue("prNumber", representativePrNumber)
}, [hasPrDocuments, representativePrNumber, form])
+
+
// 세션 정보로 담당자 정보 자동 채우기
React.useEffect(() => {
if (session?.user) {
@@ -331,8 +341,8 @@ export function CreateBiddingDialog() {
}
// 담당자 전화번호는 세션에 있다면 설정 (보통 세션에 전화번호는 없지만, 있다면)
- if (session.user.phone) {
- form.setValue("managerPhone", session.user.phone)
+ if ('phone' in session.user && session.user.phone) {
+ form.setValue("managerPhone", session.user.phone as string)
}
}
}, [session, form])
@@ -340,7 +350,7 @@ export function CreateBiddingDialog() {
// PR 아이템 추가
const addPRItem = () => {
const newItem: PRItemInfo = {
- id: `pr-${Date.now()}`,
+ id: `pr-${Math.random().toString(36).substr(2, 9)}`,
prNumber: "",
itemCode: "",
itemInfo: "",
@@ -348,6 +358,8 @@ export function CreateBiddingDialog() {
quantityUnit: "EA",
totalWeight: "",
weightUnit: "KG",
+ materialDescription: "",
+ hasSpecDocument: false,
requestedDeliveryDate: "",
specFiles: [],
isRepresentative: prItems.length === 0, // 첫 번째 아이템은 자동으로 대표 아이템
@@ -477,7 +489,7 @@ export function CreateBiddingDialog() {
const result = await createBidding(extendedData, userId)
if (result.success) {
- toast.success(result.message || "입찰이 성공적으로 생성되었습니다.")
+ toast.success((result as { success: true; message: string }).message || "입찰이 성공적으로 생성되었습니다.")
setOpen(false)
router.refresh()
@@ -624,7 +636,7 @@ export function CreateBiddingDialog() {
>
{/* 탭 영역 */}
<div className="flex-1 overflow-hidden">
- <Tabs value={activeTab} onValueChange={setActiveTab} className="h-full flex flex-col">
+ <Tabs value={activeTab} onValueChange={(value) => setActiveTab(value as TabType)} className="h-full flex flex-col">
<div className="px-6">
<div className="flex space-x-1 bg-muted p-1 rounded-lg overflow-x-auto">
<button
@@ -1305,14 +1317,30 @@ export function CreateBiddingDialog() {
<label className="text-sm font-medium">
지급조건 <span className="text-red-500">*</span>
</label>
- <Input
- placeholder="예: 월말결제, 60일"
+ <Select
value={biddingConditions.paymentTerms}
- onChange={(e) => setBiddingConditions(prev => ({
+ onValueChange={(value) => setBiddingConditions(prev => ({
...prev,
- paymentTerms: e.target.value
+ paymentTerms: value
}))}
- />
+ >
+ <SelectTrigger>
+ <SelectValue placeholder="지급조건 선택" />
+ </SelectTrigger>
+ <SelectContent>
+ {paymentTermsOptions.length > 0 ? (
+ paymentTermsOptions.map((option) => (
+ <SelectItem key={option.code} value={option.code}>
+ {option.code} {option.description && `(${option.description})`}
+ </SelectItem>
+ ))
+ ) : (
+ <SelectItem value="loading" disabled>
+ 데이터를 불러오는 중...
+ </SelectItem>
+ )}
+ </SelectContent>
+ </Select>
</div>
<div className="space-y-2">
@@ -1333,14 +1361,30 @@ export function CreateBiddingDialog() {
<label className="text-sm font-medium">
운송조건(인코텀즈) <span className="text-red-500">*</span>
</label>
- <Input
- placeholder="예: FOB, CIF 등"
+ <Select
value={biddingConditions.incoterms}
- onChange={(e) => setBiddingConditions(prev => ({
+ onValueChange={(value) => setBiddingConditions(prev => ({
...prev,
- incoterms: e.target.value
+ incoterms: value
}))}
- />
+ >
+ <SelectTrigger>
+ <SelectValue placeholder="인코텀즈 선택" />
+ </SelectTrigger>
+ <SelectContent>
+ {incotermsOptions.length > 0 ? (
+ incotermsOptions.map((option) => (
+ <SelectItem key={option.code} value={option.code}>
+ {option.code} {option.description && `(${option.description})`}
+ </SelectItem>
+ ))
+ ) : (
+ <SelectItem value="loading" disabled>
+ 데이터를 불러오는 중...
+ </SelectItem>
+ )}
+ </SelectContent>
+ </Select>
</div>
<div className="space-y-2">
diff --git a/lib/bidding/pre-quote/service.ts b/lib/bidding/pre-quote/service.ts
index 7a5db949..680a8ff5 100644
--- a/lib/bidding/pre-quote/service.ts
+++ b/lib/bidding/pre-quote/service.ts
@@ -2,13 +2,16 @@
import db from '@/db/db'
import { biddingCompanies, companyConditionResponses, biddings, prItemsForBidding, biddingDocuments, companyPrItemBids, priceAdjustmentForms } from '@/db/schema/bidding'
+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 } from 'drizzle-orm'
-import { saveFile } from '@/lib/file-stroage'
-import { downloadFile } from '@/lib/file-download'
+import { eq, inArray, and, ilike } 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 } from '@/lib/file-stroage'
// userId를 user.name으로 변환하는 유틸리티 함수
async function getUserNameById(userId: string): Promise<string> {
@@ -193,18 +196,40 @@ export async function updatePreQuoteSelection(companyIds: number[], isSelected:
// 사전견적용 업체 삭제
export async function deleteBiddingCompany(id: number) {
try {
+ // 1. 해당 업체의 초대 상태 확인
+ const company = await db
+ .select({ invitationStatus: biddingCompanies.invitationStatus })
+ .from(biddingCompanies)
+ .where(eq(biddingCompanies.id, id))
+ .then(rows => rows[0])
+
+ if (!company) {
+ return {
+ success: false,
+ error: '해당 업체를 찾을 수 없습니다.'
+ }
+ }
+
+ // 이미 초대가 발송된 경우(수락, 거절, 요청됨 등) 삭제 불가
+ if (company.invitationStatus !== 'pending') {
+ return {
+ success: false,
+ error: '이미 초대를 보낸 업체는 삭제할 수 없습니다.'
+ }
+ }
+
await db.transaction(async (tx) => {
- // 1. 먼저 관련된 조건 응답들 삭제
+ // 2. 먼저 관련된 조건 응답들 삭제
await tx.delete(companyConditionResponses)
.where(eq(companyConditionResponses.biddingCompanyId, id))
- // 2. biddingCompanies 레코드 삭제
+ // 3. biddingCompanies 레코드 삭제
await tx.delete(biddingCompanies)
.where(eq(biddingCompanies.id, id))
- })
+ })
- return {
- success: true,
+ return {
+ success: true,
message: '업체가 성공적으로 삭제되었습니다.'
}
} catch (error) {
@@ -1157,4 +1182,344 @@ export async function deletePreQuoteDocument(
error: '문서 삭제에 실패했습니다.'
}
}
- } \ No newline at end of file
+ }
+
+// 기본계약 발송 (서버 액션)
+export async function sendBiddingBasicContracts(
+ biddingId: number,
+ vendorData: Array<{
+ vendorId: number
+ vendorName: string
+ vendorCode?: string
+ vendorCountry?: string
+ selectedMainEmail: string
+ additionalEmails: string[]
+ customEmails?: Array<{ email: string; name?: string }>
+ contractRequirements: {
+ ndaYn: boolean
+ generalGtcYn: boolean
+ projectGtcYn: boolean
+ agreementYn: boolean
+ }
+ biddingCompanyId: number
+ biddingId: number
+ hasExistingContracts?: boolean
+ }>,
+ generatedPdfs: Array<{
+ key: string
+ buffer: number[]
+ fileName: string
+ }>,
+ message?: string
+) {
+ try {
+ console.log("sendBiddingBasicContracts called with:", { biddingId, vendorData: vendorData.map(v => ({ vendorId: v.vendorId, biddingCompanyId: v.biddingCompanyId, biddingId: v.biddingId })) });
+
+ // 현재 사용자 정보 조회 (임시로 첫 번째 사용자 사용)
+ const [currentUser] = await db.select().from(users).limit(1)
+
+ if (!currentUser) {
+ throw new Error("사용자 정보를 찾을 수 없습니다.")
+ }
+
+ const results = []
+ const savedContracts = []
+
+ // 트랜잭션 시작
+ 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) {
+ // 기존 계약 확인 (biddingCompanyId 기준)
+ if (vendor.hasExistingContracts) {
+ console.log(`벤더 ${vendor.vendorName}는 이미 계약이 체결되어 있어 건너뜁니다.`)
+ continue
+ }
+
+ // 벤더 정보 조회
+ const [vendorInfo] = await tx
+ .select()
+ .from(vendors)
+ .where(eq(vendors.id, vendor.vendorId))
+ .limit(1)
+
+ if (!vendorInfo) {
+ console.error(`벤더 정보를 찾을 수 없습니다: ${vendor.vendorId}`)
+ continue
+ }
+
+ // biddingCompany 정보 조회 (biddingCompanyId를 직접 사용)
+ console.log(`Looking for biddingCompany with id=${vendor.biddingCompanyId}`)
+ let [biddingCompanyInfo] = await tx
+ .select()
+ .from(biddingCompanies)
+ .where(eq(biddingCompanies.id, vendor.biddingCompanyId))
+ .limit(1)
+
+ console.log(`Found biddingCompanyInfo:`, biddingCompanyInfo)
+ if (!biddingCompanyInfo) {
+ console.error(`입찰 회사 정보를 찾을 수 없습니다: biddingCompanyId=${vendor.biddingCompanyId}`)
+ // fallback: biddingId와 vendorId로 찾기 시도
+ console.log(`Fallback: Looking for biddingCompany with biddingId=${biddingId}, companyId=${vendor.vendorId}`)
+ const [fallbackCompanyInfo] = await tx
+ .select()
+ .from(biddingCompanies)
+ .where(and(
+ eq(biddingCompanies.biddingId, biddingId),
+ eq(biddingCompanies.companyId, vendor.vendorId)
+ ))
+ .limit(1)
+ console.log(`Fallback found biddingCompanyInfo:`, fallbackCompanyInfo)
+ if (fallbackCompanyInfo) {
+ console.log(`Using fallback biddingCompanyInfo`)
+ biddingCompanyInfo = fallbackCompanyInfo
+ } else {
+ console.log(`Available biddingCompanies for biddingId=${biddingId}:`, await tx.select().from(biddingCompanies).where(eq(biddingCompanies.biddingId, biddingId)).limit(10))
+ continue
+ }
+ }
+
+ // 계약 요구사항에 따라 계약서 생성
+ const contractTypes: Array<{ type: string; templateName: string }> = []
+ if (vendor.contractRequirements.ndaYn) contractTypes.push({ type: 'NDA', templateName: '비밀' })
+ if (vendor.contractRequirements.generalGtcYn) contractTypes.push({ type: 'General_GTC', templateName: 'General GTC' })
+ 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))
+ const pdfData = generatedPdfs.find((pdf: any) =>
+ pdf.key.includes(`${vendor.vendorId}_`) &&
+ pdf.key.includes(`_${contractType.templateName}`)
+ )
+ console.log("pdfData", pdfData, "for contractType", contractType)
+ if (!pdfData) {
+ console.error(`PDF 데이터를 찾을 수 없습니다: vendorId=${vendor.vendorId}, templateName=${contractType.templateName}`)
+ continue
+ }
+
+ // 파일 저장 (rfq-last 방식)
+ const fileName = `${contractType.type}_${vendor.vendorCode || vendor.vendorId}_${vendor.biddingCompanyId}_${Date.now()}.pdf`
+ const filePath = path.join(contractsDir, fileName);
+
+ await writeFile(filePath, Buffer.from(pdfData.buffer));
+
+ // 템플릿 정보 조회 (rfq-last 방식)
+ const [template] = await db
+ .select()
+ .from(basicContractTemplates)
+ .where(
+ and(
+ ilike(basicContractTemplates.templateName, `%${contractType.templateName}%`),
+ eq(basicContractTemplates.status, "ACTIVE")
+ )
+ )
+ .limit(1);
+
+ console.log("템플릿", contractType.templateName, template);
+
+ // 기존 계약이 있는지 확인 (rfq-last 방식)
+ const [existingContract] = await tx
+ .select()
+ .from(basicContract)
+ .where(
+ and(
+ eq(basicContract.templateId, template?.id),
+ eq(basicContract.vendorId, vendor.vendorId),
+ eq(basicContract.biddingCompanyId, biddingCompanyInfo.id)
+ )
+ )
+ .limit(1);
+
+ let contractRecord;
+
+ if (existingContract) {
+ // 기존 계약이 있으면 업데이트
+ [contractRecord] = await tx
+ .update(basicContract)
+ .set({
+ requestedBy: currentUser.id,
+ status: "PENDING", // 재발송 상태
+ fileName: fileName,
+ filePath: `/contracts/generated/${fileName}`,
+ deadline: new Date(Date.now() + 10 * 24 * 60 * 60 * 1000),
+ updatedAt: new Date(),
+ })
+ .where(eq(basicContract.id, existingContract.id))
+ .returning();
+
+ console.log("기존 계약 업데이트:", contractRecord.id);
+ } else {
+ // 새 계약 생성
+ [contractRecord] = await tx
+ .insert(basicContract)
+ .values({
+ templateId: template?.id || null,
+ vendorId: vendor.vendorId,
+ biddingCompanyId: biddingCompanyInfo.id,
+ rfqCompanyId: null,
+ generalContractId: null,
+ requestedBy: currentUser.id,
+ status: 'PENDING',
+ fileName: fileName,
+ filePath: `/contracts/generated/${fileName}`,
+ deadline: new Date(Date.now() + 10 * 24 * 60 * 60 * 1000), // 10일 후
+ createdAt: new Date(),
+ updatedAt: new Date(),
+ })
+ .returning();
+
+ console.log("새 계약 생성:", contractRecord.id);
+ }
+
+ results.push({
+ vendorId: vendor.vendorId,
+ vendorName: vendor.vendorName,
+ contractId: contractRecord.id,
+ contractType: contractType.type,
+ 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, // 업데이트 여부 표시
+ // })
+ }
+
+ // 이메일 발송 (선택사항)
+ if (vendor.selectedMainEmail) {
+ try {
+ await sendEmail({
+ to: vendor.selectedMainEmail,
+ template: 'basic-contract-notification',
+ context: {
+ vendorName: vendor.vendorName,
+ biddingId: biddingId,
+ contractCount: contractTypes.length,
+ deadline: new Date(Date.now() + 10 * 24 * 60 * 60 * 1000).toLocaleDateString('ko-KR'),
+ loginUrl: `${process.env.NEXT_PUBLIC_APP_URL}/partners/bid/${biddingId}`,
+ message: message || '',
+ currentYear: new Date().getFullYear(),
+ language: 'ko'
+ }
+ })
+ } catch (emailError) {
+ console.error(`이메일 발송 실패 (${vendor.selectedMainEmail}):`, emailError)
+ // 이메일 발송 실패해도 계약 생성은 유지
+ }
+ }
+ }
+
+ return {
+ success: true,
+ message: `${results.length}개의 기본계약이 생성되었습니다.`,
+ results,
+ savedContracts,
+ totalContracts: savedContracts.length
+ }
+ })
+
+ return result
+
+ } catch (error) {
+ console.error('기본계약 발송 실패:', error)
+ throw new Error(
+ error instanceof Error
+ ? error.message
+ : '기본계약 발송 중 오류가 발생했습니다.'
+ )
+ }
+}
+
+// 기존 기본계약 조회 (서버 액션)
+export async function getExistingBasicContractsForBidding(biddingId: number) {
+ try {
+ // 해당 biddingId에 속한 biddingCompany들의 기존 기본계약 조회
+ const existingContracts = await db
+ .select({
+ id: basicContract.id,
+ vendorId: basicContract.vendorId,
+ biddingCompanyId: basicContract.biddingCompanyId,
+ biddingId: biddingCompanies.biddingId,
+ templateId: basicContract.templateId,
+ status: basicContract.status,
+ createdAt: basicContract.createdAt,
+ })
+ .from(basicContract)
+ .leftJoin(biddingCompanies, eq(basicContract.biddingCompanyId, biddingCompanies.id))
+ .where(
+ and(
+ eq(biddingCompanies.biddingId, biddingId),
+ )
+ )
+
+ return {
+ success: true,
+ contracts: existingContracts
+ }
+
+ } catch (error) {
+ console.error('기존 계약 조회 실패:', error)
+ return {
+ success: false,
+ error: '기존 계약 조회에 실패했습니다.'
+ }
+ }
+}
+
+// 선정된 업체들 조회 (서버 액션)
+export async function getSelectedVendorsForBidding(biddingId: number) {
+ try {
+ const selectedCompanies = await db
+ .select({
+ id: biddingCompanies.id,
+ companyId: biddingCompanies.companyId,
+ companyName: vendors.vendorName,
+ companyCode: vendors.vendorCode,
+ companyCountry: vendors.country,
+ contactPerson: biddingCompanies.contactPerson,
+ contactEmail: biddingCompanies.contactEmail,
+ biddingId: biddingCompanies.biddingId,
+ })
+ .from(biddingCompanies)
+ .leftJoin(vendors, eq(biddingCompanies.companyId, vendors.id))
+ .where(and(
+ eq(biddingCompanies.biddingId, biddingId),
+ eq(biddingCompanies.isPreQuoteSelected, true)
+ ))
+
+ return {
+ success: true,
+ vendors: selectedCompanies.map(company => ({
+ vendorId: company.companyId, // 실제 vendor ID
+ vendorName: company.companyName || '',
+ vendorCode: company.companyCode,
+ vendorCountry: company.companyCountry || '대한민국',
+ contactPerson: company.contactPerson,
+ contactEmail: company.contactEmail,
+ biddingCompanyId: company.id, // biddingCompany ID
+ biddingId: company.biddingId,
+ ndaYn: true, // 모든 계약 타입을 활성화 (필요에 따라 조정)
+ generalGtcYn: true,
+ projectGtcYn: true,
+ agreementYn: true
+ }))
+ }
+ } catch (error) {
+ console.error('선정된 업체 조회 실패:', error)
+ return {
+ success: false,
+ error: '선정된 업체 조회에 실패했습니다.',
+ vendors: []
+ }
+ }
+} \ No newline at end of file
diff --git a/lib/bidding/pre-quote/table/bidding-pre-quote-invitation-dialog.tsx b/lib/bidding/pre-quote/table/bidding-pre-quote-invitation-dialog.tsx
index 1b0598b7..5fc0a0ee 100644
--- a/lib/bidding/pre-quote/table/bidding-pre-quote-invitation-dialog.tsx
+++ b/lib/bidding/pre-quote/table/bidding-pre-quote-invitation-dialog.tsx
@@ -15,43 +15,236 @@ import {
} from '@/components/ui/dialog'
import { Badge } from '@/components/ui/badge'
import { BiddingCompany } from './bidding-pre-quote-vendor-columns'
-import { sendPreQuoteInvitations } from '../service'
+import { sendPreQuoteInvitations, sendBiddingBasicContracts, getExistingBasicContractsForBidding } from '../service'
+import { getActiveContractTemplates } from '../../service'
import { useToast } from '@/hooks/use-toast'
import { useTransition } from 'react'
-import { Mail, Building2, Calendar } from 'lucide-react'
+import { Mail, Building2, Calendar, FileText, CheckCircle, Info, RefreshCw } from 'lucide-react'
+import { Progress } from '@/components/ui/progress'
+import { Separator } from '@/components/ui/separator'
+import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'
+import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
+import { cn } from '@/lib/utils'
interface BiddingPreQuoteInvitationDialogProps {
open: boolean
onOpenChange: (open: boolean) => void
companies: BiddingCompany[]
+ biddingId: number
+ biddingTitle: string
+ projectName?: string
onSuccess: () => void
}
+interface BasicContractTemplate {
+ id: number
+ templateName: string
+ revision: number
+ status: string
+ filePath: string | null
+ validityPeriod: number | null
+ legalReviewRequired: boolean
+ createdAt: Date | null
+}
+
+interface SelectedContract {
+ templateId: number
+ templateName: string
+ contractType: string // templateName을 contractType으로 사용
+ checked: boolean
+}
+
+// PDF 생성 유틸리티 함수
+const generateBasicContractPdf = async (
+ template: BasicContractTemplate,
+ vendorId: number
+): Promise<{ buffer: number[]; fileName: string }> => {
+ try {
+ // 1. 템플릿 데이터 준비 (서버 API 호출)
+ const prepareResponse = await fetch("/api/contracts/prepare-template", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({
+ templateName: template.templateName,
+ vendorId,
+ }),
+ });
+
+ if (!prepareResponse.ok) {
+ throw new Error("템플릿 준비 실패");
+ }
+
+ const { template: preparedTemplate, templateData } = await prepareResponse.json();
+
+ // 2. 템플릿 파일 다운로드
+ const templateResponse = await fetch("/api/contracts/get-template", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ templatePath: preparedTemplate.filePath }),
+ });
+
+ const templateBlob = await templateResponse.blob();
+ const templateFile = new window.File([templateBlob], "template.docx", {
+ type: "application/vnd.openxmlformats-officedocument.wordprocessingml.document"
+ });
+
+ // 3. PDFTron WebViewer로 PDF 변환
+ const { default: WebViewer } = await import("@pdftron/webviewer");
+
+ const tempDiv = document.createElement('div');
+ tempDiv.style.display = 'none';
+ document.body.appendChild(tempDiv);
+
+ try {
+ const instance = await WebViewer(
+ {
+ path: "/pdftronWeb",
+ licenseKey: process.env.NEXT_PUBLIC_PDFTRON_WEBVIEW_KEY,
+ fullAPI: true,
+ },
+ tempDiv
+ );
+
+ const { Core } = instance;
+ const { createDocument } = Core;
+
+ const templateDoc = await createDocument(templateFile, {
+ filename: templateFile.name,
+ extension: 'docx',
+ });
+
+ // 변수 치환 적용
+ await templateDoc.applyTemplateValues(templateData);
+
+ // PDF 변환
+ const fileData = await templateDoc.getFileData();
+ const pdfBuffer = await Core.officeToPDFBuffer(fileData, { extension: 'docx' });
+
+ const fileName = `${template.templateName}_${Date.now()}.pdf`;
+
+ return {
+ buffer: Array.from(pdfBuffer), // Uint8Array를 일반 배열로 변환
+ fileName
+ };
+
+ } finally {
+ if (tempDiv.parentNode) {
+ document.body.removeChild(tempDiv);
+ }
+ }
+ } catch (error) {
+ console.error(`기본계약 PDF 생성 실패 (${template.templateName}):`, error);
+ throw error;
+ }
+};
+
export function BiddingPreQuoteInvitationDialog({
open,
onOpenChange,
companies,
+ biddingId,
+ biddingTitle,
+ projectName,
onSuccess
}: BiddingPreQuoteInvitationDialogProps) {
const { toast } = useToast()
const [isPending, startTransition] = useTransition()
const [selectedCompanyIds, setSelectedCompanyIds] = React.useState<number[]>([])
const [preQuoteDeadline, setPreQuoteDeadline] = React.useState('')
+ const [additionalMessage, setAdditionalMessage] = React.useState('')
+
+ // 기본계약 관련 상태
+ const [existingContracts, setExistingContracts] = React.useState<any[]>([])
+ const [isGeneratingPdfs, setIsGeneratingPdfs] = React.useState(false)
+ const [pdfGenerationProgress, setPdfGenerationProgress] = React.useState(0)
+ const [currentGeneratingContract, setCurrentGeneratingContract] = React.useState('')
+
+ // 기본계약서 템플릿 관련 상태
+ const [availableTemplates, setAvailableTemplates] = React.useState<BasicContractTemplate[]>([])
+ const [selectedContracts, setSelectedContracts] = React.useState<SelectedContract[]>([])
+ const [isLoadingTemplates, setIsLoadingTemplates] = React.useState(false)
// 초대 가능한 업체들 (pending 상태인 업체들)
- const invitableCompanies = companies.filter(company =>
+ const invitableCompanies = React.useMemo(() => companies.filter(company =>
company.invitationStatus === 'pending' && company.companyName
- )
+ ), [companies])
+
+ // 다이얼로그가 열릴 때 기존 계약 조회 및 템플릿 로드
+ React.useEffect(() => {
+ if (open) {
+ const fetchInitialData = async () => {
+ setIsLoadingTemplates(true);
+ try {
+ const [contractsResult, templatesData] = await Promise.all([
+ getExistingBasicContractsForBidding(biddingId),
+ getActiveContractTemplates()
+ ]);
+
+ // 기존 계약 조회 - 서버 액션 사용
+ const existingContractsResult = await getExistingBasicContractsForBidding(biddingId);
+ setExistingContracts(existingContractsResult.success ? existingContractsResult.contracts || [] : []);
+
+ // 템플릿 로드 (4개 타입만 필터링)
+ // 4개 템플릿 타입만 필터링: 비밀, General, Project, 기술자료
+ const allowedTemplateNames = ['비밀', 'General GTC', '기술', '기술자료'];
+ const filteredTemplates = (templatesData.templates || []).filter((template: any) =>
+ allowedTemplateNames.some(allowedName =>
+ template.templateName.includes(allowedName) ||
+ allowedName.includes(template.templateName)
+ )
+ );
+ setAvailableTemplates(filteredTemplates as BasicContractTemplate[]);
+ const initialSelected = filteredTemplates.map((template: any) => ({
+ templateId: template.id,
+ templateName: template.templateName,
+ contractType: template.templateName,
+ checked: false
+ }));
+ setSelectedContracts(initialSelected);
+
+ } catch (error) {
+ console.error('초기 데이터 로드 실패:', error);
+ toast({
+ title: '오류',
+ description: '기본 정보를 불러오는 데 실패했습니다.',
+ variant: 'destructive',
+ });
+ setExistingContracts([]);
+ setAvailableTemplates([]);
+ setSelectedContracts([]);
+ } finally {
+ setIsLoadingTemplates(false);
+ }
+ }
+ fetchInitialData();
+ }
+ }, [open, biddingId, toast]);
- const handleSelectAll = (checked: boolean) => {
+ const handleSelectAll = (checked: boolean | 'indeterminate') => {
if (checked) {
- setSelectedCompanyIds(invitableCompanies.map(company => company.id))
+ // 기존 계약이 없는 업체만 선택
+ const availableCompanies = invitableCompanies.filter(company =>
+ !existingContracts.some(ec => ec.vendorId === company.companyId)
+ )
+ setSelectedCompanyIds(availableCompanies.map(company => company.id))
} else {
setSelectedCompanyIds([])
}
}
const handleSelectCompany = (companyId: number, checked: boolean) => {
+ const company = invitableCompanies.find(c => c.id === companyId)
+ const hasExistingContract = company ? existingContracts.some(ec => ec.vendorId === company.companyId) : false
+
+ if (hasExistingContract) {
+ toast({
+ title: '선택 불가',
+ description: '이미 기본계약서를 받은 업체는 다시 선택할 수 없습니다.',
+ variant: 'default',
+ })
+ return
+ }
+
if (checked) {
setSelectedCompanyIds(prev => [...prev, companyId])
} else {
@@ -59,6 +252,24 @@ export function BiddingPreQuoteInvitationDialog({
}
}
+ // 기본계약서 선택 토글
+ const toggleContractSelection = (templateId: number) => {
+ setSelectedContracts(prev =>
+ prev.map(contract =>
+ contract.templateId === templateId
+ ? { ...contract, checked: !contract.checked }
+ : contract
+ )
+ )
+ }
+
+ // 모든 기본계약서 선택/해제
+ const toggleAllContractSelection = (checked: boolean | 'indeterminate') => {
+ setSelectedContracts(prev =>
+ prev.map(contract => ({ ...contract, checked: !!checked }))
+ )
+ }
+
const handleSendInvitations = () => {
if (selectedCompanyIds.length === 0) {
toast({
@@ -69,27 +280,145 @@ export function BiddingPreQuoteInvitationDialog({
return
}
- startTransition(async () => {
- const response = await sendPreQuoteInvitations(
- selectedCompanyIds,
- preQuoteDeadline || undefined
+ const selectedContractTemplates = selectedContracts.filter(c => c.checked);
+ const companiesForContracts = invitableCompanies.filter(company => selectedCompanyIds.includes(company.id));
+
+ const vendorsToGenerateContracts = companiesForContracts.filter(company =>
+ !existingContracts.some(ec =>
+ ec.vendorId === company.companyId && ec.biddingCompanyId === company.id
)
-
- if (response.success) {
+ );
+
+ startTransition(async () => {
+ try {
+ // 1. 사전견적 초대 발송
+ const invitationResponse = await sendPreQuoteInvitations(
+ selectedCompanyIds,
+ preQuoteDeadline || undefined
+ )
+
+ if (!invitationResponse.success) {
+ toast({
+ title: '초대 발송 실패',
+ description: invitationResponse.error,
+ variant: 'destructive',
+ })
+ return
+ }
+
+ // 2. 기본계약 발송 (선택된 템플릿과 업체가 있는 경우)
+ let contractResponse: Awaited<ReturnType<typeof sendBiddingBasicContracts>> | null = null
+ if (selectedContractTemplates.length > 0 && selectedCompanyIds.length > 0) {
+ setIsGeneratingPdfs(true)
+ setPdfGenerationProgress(0)
+
+ const generatedPdfsMap = new Map<string, { buffer: number[], fileName: string }>()
+
+ let generatedCount = 0;
+ for (const vendor of vendorsToGenerateContracts) {
+ for (const contract of selectedContractTemplates) {
+ setCurrentGeneratingContract(`${vendor.companyName} - ${contract.templateName}`);
+ const templateDetails = availableTemplates.find(t => t.id === contract.templateId);
+
+ if (templateDetails) {
+ const pdfData = await generateBasicContractPdf(templateDetails, vendor.companyId);
+ // sendBiddingBasicContracts와 동일한 키 형식 사용
+ let contractType = '';
+ if (contract.templateName.includes('비밀')) {
+ contractType = 'NDA';
+ } else if (contract.templateName.includes('General GTC')) {
+ contractType = 'General_GTC';
+ } else if (contract.templateName.includes('기술') && !contract.templateName.includes('기술자료')) {
+ contractType = 'Project_GTC';
+ } else if (contract.templateName.includes('기술자료')) {
+ contractType = '기술자료';
+ }
+ const key = `${vendor.companyId}_${contractType}_${contract.templateName}`;
+ generatedPdfsMap.set(key, pdfData);
+ }
+ }
+ generatedCount++;
+ setPdfGenerationProgress((generatedCount / vendorsToGenerateContracts.length) * 100);
+ }
+
+ setIsGeneratingPdfs(false);
+
+ const vendorData = companiesForContracts.map(company => {
+ // 선택된 템플릿에 따라 contractRequirements 동적으로 설정
+ const contractRequirements = {
+ ndaYn: selectedContractTemplates.some(c => c.templateName.includes('비밀')),
+ generalGtcYn: selectedContractTemplates.some(c => c.templateName.includes('General GTC')),
+ projectGtcYn: selectedContractTemplates.some(c => c.templateName.includes('기술') && !c.templateName.includes('기술자료')),
+ agreementYn: selectedContractTemplates.some(c => c.templateName.includes('기술자료'))
+ };
+
+ return {
+ vendorId: company.companyId,
+ vendorName: company.companyName || '',
+ vendorCode: company.companyCode,
+ vendorCountry: '대한민국',
+ selectedMainEmail: company.contactEmail || '',
+ contactPerson: company.contactPerson,
+ contactEmail: company.contactEmail,
+ biddingCompanyId: company.id,
+ biddingId: biddingId,
+ hasExistingContracts: existingContracts.some(ec =>
+ ec.vendorId === company.companyId && ec.biddingCompanyId === company.id
+ ),
+ contractRequirements,
+ additionalEmails: [],
+ customEmails: []
+ };
+ });
+
+ const pdfsArray = Array.from(generatedPdfsMap.entries()).map(([key, data]) => ({
+ key,
+ buffer: data.buffer,
+ fileName: data.fileName,
+ }));
+
+ console.log("Calling sendBiddingBasicContracts with biddingId:", biddingId);
+ console.log("vendorData:", vendorData.map(v => ({ vendorId: v.vendorId, biddingCompanyId: v.biddingCompanyId, biddingId: v.biddingId })));
+
+ contractResponse = await sendBiddingBasicContracts(
+ biddingId,
+ vendorData,
+ pdfsArray,
+ additionalMessage
+ );
+ }
+
+ let successMessage = '사전견적 초대가 성공적으로 발송되었습니다.';
+ if (contractResponse && contractResponse.success) {
+ successMessage += `\n${contractResponse.message}`;
+ }
+
toast({
title: '성공',
- description: response.message,
+ description: successMessage,
})
- setSelectedCompanyIds([])
- setPreQuoteDeadline('')
- onOpenChange(false)
- onSuccess()
- } else {
+
+ // 상태 초기화
+ setSelectedCompanyIds([]);
+ setPreQuoteDeadline('');
+ setAdditionalMessage('');
+ setExistingContracts([]);
+ setIsGeneratingPdfs(false);
+ setPdfGenerationProgress(0);
+ setCurrentGeneratingContract('');
+ setSelectedContracts(prev => prev.map(c => ({ ...c, checked: false })));
+
+ onOpenChange(false);
+ onSuccess();
+
+ } catch (error) {
+ console.error('발송 실패:', error);
toast({
title: '오류',
- description: response.error,
+ description: '발송 중 오류가 발생했습니다. 잠시 후 다시 시도해 주세요.',
variant: 'destructive',
- })
+ });
+ setIsGeneratingPdfs(false);
}
})
}
@@ -99,113 +428,346 @@ export function BiddingPreQuoteInvitationDialog({
if (!open) {
setSelectedCompanyIds([])
setPreQuoteDeadline('')
+ setAdditionalMessage('')
+ setExistingContracts([])
+ setIsGeneratingPdfs(false)
+ setPdfGenerationProgress(0)
+ setCurrentGeneratingContract('')
+ setSelectedContracts([])
}
}
+ const selectedContractCount = selectedContracts.filter(c => c.checked).length;
+ const selectedCompanyCount = selectedCompanyIds.length;
+ const companiesToReceiveContracts = invitableCompanies.filter(company => selectedCompanyIds.includes(company.id));
+
+ // 기존 계약이 없는 업체들만 계산
+ const availableCompanies = invitableCompanies.filter(company =>
+ !existingContracts.some(ec => ec.vendorId === company.companyId)
+ );
+ const selectedAvailableCompanyCount = selectedCompanyIds.filter(id =>
+ availableCompanies.some(company => company.id === id)
+ ).length;
+
+ // 선택된 업체들 중 기존 계약이 있는 업체들
+ const selectedCompaniesWithExistingContracts = invitableCompanies.filter(company =>
+ selectedCompanyIds.includes(company.id) &&
+ existingContracts.some(ec => ec.vendorId === company.companyId)
+ );
+
return (
<Dialog open={open} onOpenChange={handleOpenChange}>
- <DialogContent className="sm:max-w-[600px]">
+ <DialogContent className="sm:max-w-[800px] max-h-[90vh] flex flex-col">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<Mail className="w-5 h-5" />
- 사전견적 초대 발송
+ 사전견적 초대 및 기본계약 발송
</DialogTitle>
<DialogDescription>
- 선택한 업체들에게 사전견적 요청을 발송합니다.
+ 선택한 업체들에게 사전견적 요청과 기본계약서를 발송합니다.
</DialogDescription>
</DialogHeader>
-
- <div className="py-4">
- {invitableCompanies.length === 0 ? (
- <div className="text-center py-8 text-muted-foreground">
- 초대 가능한 업체가 없습니다.
+
+ <div className="flex-1 overflow-y-auto px-1" style={{ maxHeight: 'calc(70vh - 200px)' }}>
+ <div className="space-y-6 pr-4">
+ {/* 견적 마감일 설정 */}
+ <div className="mb-6 p-4 border rounded-lg bg-muted/30">
+ <Label htmlFor="preQuoteDeadline" className="text-sm font-medium mb-2 flex items-center gap-2">
+ <Calendar className="w-4 h-4" />
+ 견적 마감일 (선택사항)
+ </Label>
+ <Input
+ id="preQuoteDeadline"
+ type="datetime-local"
+ value={preQuoteDeadline}
+ onChange={(e) => setPreQuoteDeadline(e.target.value)}
+ className="w-full"
+ />
+ <p className="text-xs text-muted-foreground mt-1">
+ 설정하지 않으면 마감일 없이 초대가 발송됩니다.
+ </p>
</div>
- ) : (
- <>
- {/* 견적마감일 설정 */}
- <div className="mb-6 p-4 border rounded-lg bg-muted/30">
- <Label htmlFor="preQuoteDeadline" className="text-sm font-medium mb-2 flex items-center gap-2">
- <Calendar className="w-4 h-4" />
- 견적 마감일 (선택사항)
- </Label>
- <Input
- id="preQuoteDeadline"
- type="datetime-local"
- value={preQuoteDeadline}
- onChange={(e) => setPreQuoteDeadline(e.target.value)}
- className="w-full"
- />
- <p className="text-xs text-muted-foreground mt-1">
- 설정하지 않으면 마감일 없이 초대가 발송됩니다.
- </p>
- </div>
-
- {/* 전체 선택 */}
- <div className="flex items-center space-x-2 mb-4 pb-2 border-b">
- <Checkbox
- id="select-all"
- checked={selectedCompanyIds.length === invitableCompanies.length}
- onCheckedChange={handleSelectAll}
- />
- <label htmlFor="select-all" className="font-medium">
- 전체 선택 ({invitableCompanies.length}개 업체)
- </label>
- </div>
-
- {/* 업체 목록 */}
- <div className="space-y-3 max-h-80 overflow-y-auto">
- {invitableCompanies.map((company) => (
- <div key={company.id} className="flex items-center space-x-3 p-3 border rounded-lg">
- <Checkbox
- id={`company-${company.id}`}
- checked={selectedCompanyIds.includes(company.id)}
- onCheckedChange={(checked) => handleSelectCompany(company.id, !!checked)}
- />
- <div className="flex-1">
+
+ {/* 기존 계약 정보 알림 */}
+ {existingContracts.length > 0 && (
+ <Alert className="border-orange-500 bg-orange-50">
+ <Info className="h-4 w-4 text-orange-600" />
+ <AlertTitle className="text-orange-800">기존 계약 정보</AlertTitle>
+ <AlertDescription className="text-orange-700">
+ 이미 기본계약을 받은 업체가 있습니다.
+ 해당 업체들은 초대 대상에서 제외되며, 계약서 재생성도 건너뜁니다.
+ </AlertDescription>
+ </Alert>
+ )}
+
+ {/* 업체 선택 섹션 */}
+ <Card className="border-2 border-dashed">
+ <CardHeader className="pb-3">
+ <CardTitle className="flex items-center gap-2 text-base">
+ <Building2 className="h-5 w-5 text-green-600" />
+ 초대 대상 업체
+ </CardTitle>
+ </CardHeader>
+ <CardContent className="space-y-4">
+ {invitableCompanies.length === 0 ? (
+ <div className="text-center py-8 text-muted-foreground">
+ 초대 가능한 업체가 없습니다.
+ </div>
+ ) : (
+ <>
+ <div className="flex items-center justify-between p-3 bg-muted/50 rounded-lg">
<div className="flex items-center gap-2">
- <Building2 className="w-4 h-4" />
- <span className="font-medium">{company.companyName}</span>
- <Badge variant="outline" className="text-xs">
- {company.companyCode}
- </Badge>
+ <Checkbox
+ id="select-all-companies"
+ checked={selectedAvailableCompanyCount === availableCompanies.length && availableCompanies.length > 0}
+ onCheckedChange={handleSelectAll}
+ />
+ <Label htmlFor="select-all-companies" className="font-medium">
+ 전체 선택 ({availableCompanies.length}개 업체)
+ </Label>
</div>
- {company.notes && (
- <p className="text-sm text-muted-foreground mt-1">
- {company.notes}
- </p>
- )}
+ <Badge variant="outline">
+ {selectedCompanyCount}개 선택됨
+ </Badge>
</div>
- <Badge variant="outline">
- 대기중
- </Badge>
+
+ <div className="space-y-3 max-h-80 overflow-y-auto">
+ {invitableCompanies.map((company) => {
+ const hasExistingContract = existingContracts.some(ec => ec.vendorId === company.companyId);
+ return (
+ <div key={company.id} className={cn("flex items-center space-x-3 p-3 border rounded-lg transition-colors",
+ selectedCompanyIds.includes(company.id) && !hasExistingContract && "border-green-500 bg-green-50",
+ hasExistingContract && "border-orange-500 bg-orange-50 opacity-75"
+ )}>
+ <Checkbox
+ id={`company-${company.id}`}
+ checked={selectedCompanyIds.includes(company.id)}
+ disabled={hasExistingContract}
+ onCheckedChange={(checked) => handleSelectCompany(company.id, !!checked)}
+ />
+ <div className="flex-1">
+ <div className="flex items-center gap-2">
+ <span className={cn("font-medium", hasExistingContract && "text-muted-foreground")}>
+ {company.companyName}
+ </span>
+ <Badge variant="outline" className="text-xs">
+ {company.companyCode}
+ </Badge>
+ {hasExistingContract && (
+ <Badge variant="secondary" className="text-xs">
+ <CheckCircle className="h-3 w-3 mr-1" />
+ 계약 체결됨
+ </Badge>
+ )}
+ </div>
+ {hasExistingContract && (
+ <p className="text-xs text-orange-600 mt-1">
+ 이미 기본계약서를 받은 업체입니다. 선택에서 제외됩니다.
+ </p>
+ )}
+ </div>
+ </div>
+ )
+ })}
+ </div>
+ </>
+ )}
+ </CardContent>
+ </Card>
+
+ {/* 선택된 업체 중 기존 계약이 있는 경우 경고 */}
+ {selectedCompaniesWithExistingContracts.length > 0 && (
+ <Alert className="border-red-500 bg-red-50">
+ <Info className="h-4 w-4 text-red-600" />
+ <AlertTitle className="text-red-800">선택한 업체 중 제외될 업체</AlertTitle>
+ <AlertDescription className="text-red-700">
+ 선택한 {selectedCompaniesWithExistingContracts.length}개 업체가 이미 기본계약서를 받았습니다.
+ 이 업체들은 초대 발송 및 계약서 생성에서 제외됩니다.
+ <br />
+ <strong>실제 발송 대상: {selectedCompanyCount - selectedCompaniesWithExistingContracts.length}개 업체</strong>
+ </AlertDescription>
+ </Alert>
+ )}
+
+ {/* 기본계약서 선택 섹션 */}
+ <Separator />
+ <Card className="border-2 border-dashed">
+ <CardHeader className="pb-3">
+ <CardTitle className="flex items-center gap-2 text-base">
+ <FileText className="h-5 w-5 text-blue-600" />
+ 기본계약서 선택 (선택된 업체에만 발송)
+ </CardTitle>
+ </CardHeader>
+ <CardContent className="space-y-4">
+ {isLoadingTemplates ? (
+ <div className="text-center py-6">
+ <RefreshCw className="h-6 w-6 animate-spin mx-auto mb-2 text-blue-600" />
+ <p className="text-sm text-muted-foreground">기본계약서 템플릿을 불러오는 중...</p>
+ </div>
+ ) : (
+ <div className="space-y-4">
+ {selectedCompanyCount === 0 && (
+ <Alert className="border-red-500 bg-red-50">
+ <Info className="h-4 w-4 text-red-600" />
+ <AlertTitle className="text-red-800">알림</AlertTitle>
+ <AlertDescription className="text-red-700">
+ 기본계약서를 발송할 업체를 먼저 선택해주세요.
+ </AlertDescription>
+ </Alert>
+ )}
+ {availableTemplates.length === 0 ? (
+ <div className="text-center py-8 text-muted-foreground">
+ <FileText className="h-12 w-12 mx-auto mb-4 opacity-50" />
+ <p>사용 가능한 기본계약서 템플릿이 없습니다.</p>
+ </div>
+ ) : (
+ <>
+ <div className="flex items-center justify-between p-3 bg-muted/50 rounded-lg">
+ <div className="flex items-center gap-2">
+ <Checkbox
+ id="select-all-contracts"
+ checked={selectedContracts.length > 0 && selectedContracts.every(c => c.checked)}
+ onCheckedChange={toggleAllContractSelection}
+ />
+ <Label htmlFor="select-all-contracts" className="font-medium">
+ 전체 선택 ({availableTemplates.length}개 템플릿)
+ </Label>
+ </div>
+ <Badge variant="outline">
+ {selectedContractCount}개 선택됨
+ </Badge>
+ </div>
+ <div className="grid gap-3 max-h-60 overflow-y-auto">
+ {selectedContracts.map((contract) => (
+ <div
+ key={contract.templateId}
+ className={cn(
+ "flex items-center justify-between p-3 border rounded-lg hover:bg-muted/50 transition-colors cursor-pointer",
+ contract.checked && "border-blue-500 bg-blue-50"
+ )}
+ onClick={() => toggleContractSelection(contract.templateId)}
+ >
+ <div className="flex items-center gap-3">
+ <Checkbox
+ id={`contract-${contract.templateId}`}
+ checked={contract.checked}
+ onCheckedChange={() => toggleContractSelection(contract.templateId)}
+ />
+ <div className="flex-1">
+ <Label
+ htmlFor={`contract-${contract.templateId}`}
+ className="font-medium cursor-pointer"
+ >
+ {contract.templateName}
+ </Label>
+ <p className="text-xs text-muted-foreground mt-1">
+ {contract.contractType}
+ </p>
+ </div>
+ </div>
+ </div>
+ ))}
+ </div>
+ </>
+ )}
+ {selectedContractCount > 0 && (
+ <div className="mt-4 p-3 bg-green-50 border border-green-200 rounded-lg">
+ <div className="flex items-center gap-2 mb-2">
+ <CheckCircle className="h-4 w-4 text-green-600" />
+ <span className="font-medium text-green-900 text-sm">
+ 선택된 기본계약서 ({selectedContractCount}개)
+ </span>
+ </div>
+ <ul className="space-y-1 text-xs text-green-800 list-disc list-inside">
+ {selectedContracts.filter(c => c.checked).map((contract) => (
+ <li key={contract.templateId}>
+ {contract.templateName}
+ </li>
+ ))}
+ </ul>
+ </div>
+ )}
</div>
- ))}
- </div>
-
- {selectedCompanyIds.length > 0 && (
- <div className="mt-4 p-3 bg-primary/5 rounded-lg">
- <p className="text-sm text-primary">
- <strong>{selectedCompanyIds.length}개 업체</strong>에 사전견적 초대를 발송합니다.
- </p>
+ )}
+ </CardContent>
+ </Card>
+
+ {/* 추가 메시지 */}
+ <div className="space-y-2">
+ <Label htmlFor="contractMessage" className="text-sm font-medium">
+ 계약서 추가 메시지 (선택사항)
+ </Label>
+ <textarea
+ id="contractMessage"
+ className="w-full min-h-[60px] p-3 text-sm border rounded-lg resize-none focus:outline-none focus:ring-2 focus:ring-primary"
+ placeholder="기본계약서와 함께 보낼 추가 메시지를 입력하세요..."
+ value={additionalMessage}
+ onChange={(e) => setAdditionalMessage(e.target.value)}
+ />
+ </div>
+
+ {/* PDF 생성 진행 상황 */}
+ {isGeneratingPdfs && (
+ <Alert className="border-blue-500 bg-blue-50">
+ <div className="space-y-3">
+ <div className="flex items-center gap-2">
+ <RefreshCw className="h-4 w-4 animate-spin text-blue-600" />
+ <AlertTitle className="text-blue-800">기본계약서 생성 중</AlertTitle>
+ </div>
+ <AlertDescription>
+ <div className="space-y-2">
+ <p className="text-sm text-blue-700">{currentGeneratingContract}</p>
+ <Progress value={pdfGenerationProgress} className="h-2" />
+ <p className="text-xs text-blue-600">
+ {Math.round(pdfGenerationProgress)}% 완료
+ </p>
+ </div>
+ </AlertDescription>
</div>
- )}
- </>
- )}
+ </Alert>
+ )}
+ </div>
</div>
- <DialogFooter>
- <Button variant="outline" onClick={() => handleOpenChange(false)}>
- 취소
- </Button>
- <Button
- onClick={handleSendInvitations}
- disabled={isPending || selectedCompanyIds.length === 0}
- >
- <Mail className="w-4 h-4 mr-2" />
- 초대 발송
- </Button>
+ <DialogFooter className="flex-col sm:flex-row-reverse sm:justify-between items-center px-4 pt-4">
+ <div className="flex gap-2 w-full sm:w-auto">
+ <Button variant="outline" onClick={() => handleOpenChange(false)} className="w-full sm:w-auto">
+ 취소
+ </Button>
+ <Button
+ onClick={handleSendInvitations}
+ disabled={isPending || selectedCompanyCount === 0 || isGeneratingPdfs}
+ className="w-full sm:w-auto"
+ >
+ {isPending ? (
+ <>
+ <RefreshCw className="w-4 h-4 mr-2 animate-spin" />
+ 발송 중...
+ </>
+ ) : (
+ <>
+ <Mail className="w-4 h-4 mr-2" />
+ 초대 발송 및 계약서 생성
+ </>
+ )}
+ </Button>
+ </div>
+ {/* {(selectedCompanyCount > 0 || selectedContractCount > 0) && (
+ <div className="mt-4 sm:mt-0 text-sm text-muted-foreground">
+ {selectedCompanyCount > 0 && (
+ <p>
+ <strong>{selectedCompanyCount}개 업체</strong>에 초대를 발송합니다.
+ </p>
+ )}
+ {selectedContractCount > 0 && selectedCompanyCount > 0 && (
+ <p>
+ 이 중 <strong>{companiesToReceiveContracts.length}개 업체</strong>에 <strong>{selectedContractCount}개</strong>의 기본계약서를 발송합니다.
+ </p>
+ )}
+ </div>
+ )} */}
</DialogFooter>
</DialogContent>
</Dialog>
)
-}
+} \ No newline at end of file
diff --git a/lib/bidding/pre-quote/table/bidding-pre-quote-vendor-table.tsx b/lib/bidding/pre-quote/table/bidding-pre-quote-vendor-table.tsx
index 7ea05721..5f600882 100644
--- a/lib/bidding/pre-quote/table/bidding-pre-quote-vendor-table.tsx
+++ b/lib/bidding/pre-quote/table/bidding-pre-quote-vendor-table.tsx
@@ -108,8 +108,6 @@ export function BiddingPreQuoteVendorTableContent({
const [selectedCompanyForAttachments, setSelectedCompanyForAttachments] = React.useState<BiddingCompany | null>(null)
const handleDelete = (company: BiddingCompany) => {
- if (!confirm(`${company.companyName} 업체를 삭제하시겠습니까?`)) return
-
startTransition(async () => {
const response = await deleteBiddingCompany(company.id)
diff --git a/lib/bidding/pre-quote/table/bidding-pre-quote-vendor-toolbar-actions.tsx b/lib/bidding/pre-quote/table/bidding-pre-quote-vendor-toolbar-actions.tsx
index 6c209e2d..34e53fb2 100644
--- a/lib/bidding/pre-quote/table/bidding-pre-quote-vendor-toolbar-actions.tsx
+++ b/lib/bidding/pre-quote/table/bidding-pre-quote-vendor-toolbar-actions.tsx
@@ -110,6 +110,9 @@ export function BiddingPreQuoteVendorToolbarActions({
open={isInvitationDialogOpen}
onOpenChange={setIsInvitationDialogOpen}
companies={biddingCompanies}
+ biddingId={biddingId}
+ biddingTitle={bidding.title}
+ projectName={bidding.projectName}
onSuccess={onSuccess}
/>
diff --git a/lib/bidding/service.ts b/lib/bidding/service.ts
index 70458a15..90a379e1 100644
--- a/lib/bidding/service.ts
+++ b/lib/bidding/service.ts
@@ -11,7 +11,10 @@ import {
specificationMeetings,
prDocuments,
biddingConditions,
- users
+ users,
+ basicContractTemplates,
+ paymentTerms,
+ incoterms
} from '@/db/schema'
import {
eq,
@@ -1336,4 +1339,86 @@ export async function updateBiddingConditions(
error: error instanceof Error ? error.message : '입찰 조건 업데이트 중 오류가 발생했습니다.'
}
}
+}
+
+// 활성 템플릿 조회 서버 액션
+// 입찰 조건 옵션 관련 서버 액션들
+export async function getActivePaymentTerms() {
+ try {
+ const result = await db
+ .select({
+ code: paymentTerms.code,
+ description: paymentTerms.description,
+ isActive: paymentTerms.isActive,
+ createdAt: paymentTerms.createdAt,
+ })
+ .from(paymentTerms)
+ .where(eq(paymentTerms.isActive, true))
+ .orderBy(paymentTerms.createdAt)
+
+ return {
+ success: true,
+ data: result
+ }
+ } catch (error) {
+ console.error('Error fetching active payment terms:', error)
+ return {
+ success: false,
+ error: '지급조건 조회 중 오류가 발생했습니다.'
+ }
+ }
+}
+
+export async function getActiveIncoterms() {
+ try {
+ const result = await db
+ .select({
+ code: incoterms.code,
+ description: incoterms.description,
+ isActive: incoterms.isActive,
+ createdAt: incoterms.createdAt,
+ })
+ .from(incoterms)
+ .where(eq(incoterms.isActive, true))
+ .orderBy(incoterms.createdAt)
+
+ return {
+ success: true,
+ data: result
+ }
+ } catch (error) {
+ console.error('Error fetching active incoterms:', error)
+ return {
+ success: false,
+ error: '운송조건 조회 중 오류가 발생했습니다.'
+ }
+ }
+}
+
+export async function getActiveContractTemplates() {
+ try {
+ // 활성 상태의 템플릿들 조회
+ const templates = await db
+ .select({
+ id: basicContractTemplates.id,
+ templateName: basicContractTemplates.templateName,
+ revision: basicContractTemplates.revision,
+ status: basicContractTemplates.status,
+ filePath: basicContractTemplates.filePath,
+ validityPeriod: basicContractTemplates.validityPeriod,
+ legalReviewRequired: basicContractTemplates.legalReviewRequired,
+ createdAt: basicContractTemplates.createdAt,
+ })
+ .from(basicContractTemplates)
+ .where(eq(basicContractTemplates.status, 'ACTIVE'))
+ .orderBy(basicContractTemplates.templateName);
+
+ return {
+ templates
+ };
+
+ } catch (error) {
+ console.error('활성 템플릿 조회 실패:', error);
+ throw new Error('템플릿 조회에 실패했습니다.');
+ }
} \ No newline at end of file