"use client" import * as React from "react" import { type Row } from "@tanstack/react-table" import { Loader, FileText, X, Plus } from "lucide-react" import { toast } from "sonner" import { useMediaQuery } from "@/hooks/use-media-query" import { Button } from "@/components/ui/button" import { Dialog, DialogClose, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, DialogTrigger, } from "@/components/ui/dialog" import { Drawer, DrawerClose, DrawerContent, DrawerDescription, DrawerFooter, DrawerHeader, DrawerTitle, DrawerTrigger, } from "@/components/ui/drawer" import { Checkbox } from "@/components/ui/checkbox" import { Label } from "@/components/ui/label" import { Badge } from "@/components/ui/badge" import { Progress } from "@/components/ui/progress" import { Vendor } from "@/db/schema/vendors" import { requestBasicContractInfo } from "@/lib/vendors/service" import { getALLBasicContractTemplates } from "@/lib/basic-contract/service" import type { BasicContractTemplate } from "@/db/schema" import { saveNdaAttachments } from "@/lib/vendors/service" import { useSession } from "next-auth/react" import { useRouter } from "next/navigation" import { createGtcVendorDocuments, createProjectGtcVendorDocuments, getStandardGtcDocumentId } from "@/lib/gtc-contract/service" export interface SendContractDialogProps extends React.ComponentPropsWithoutRef { vendors: Row["original"][] showTrigger?: boolean onSuccess?: () => void title?: string description?: string triggerLabel?: string } export function SendContractDialog({ vendors, showTrigger = true, onSuccess, title = "기본계약서 발송", description = "선택된 협력업체들에게 기본계약서를 발송합니다.", triggerLabel = "기본계약서 발송", ...props }: SendContractDialogProps) { const [isProcessing, startProcessingTransition] = React.useTransition() const isDesktop = useMediaQuery("(min-width: 640px)") const { data: session } = useSession() const router = useRouter() // 상태 관리 const [basicContractTemplates, setBasicContractTemplates] = React.useState([]) const [selectedTemplateIds, setSelectedTemplateIds] = React.useState([]) const [isLoadingTemplates, setIsLoadingTemplates] = React.useState(false) // 비밀유지 계약서 첨부파일 관련 상태 const [ndaAttachments, setNdaAttachments] = React.useState([]) const [isUploadingNdaFiles, setIsUploadingNdaFiles] = React.useState(false) // 프로그레스 관련 상태 const [progressValue, setProgressValue] = React.useState(0) const [currentStep, setCurrentStep] = React.useState("") const [showProgress, setShowProgress] = React.useState(false) // 기본계약서 템플릿 로딩 및 자동 선택 React.useEffect(() => { setIsLoadingTemplates(true) getALLBasicContractTemplates() .then((templates) => { setBasicContractTemplates(templates) // 벤더 국가별 자동 선택 로직 if (vendors.length > 0) { const isAllForeign = vendors.every(vendor => vendor.country !== 'KR') const isAllDomestic = vendors.every(vendor => vendor.country === 'KR') if (isAllForeign) { // 외자: 준법서약 (영문), GTC 선택 (GTC는 1개만 선택하도록) const foreignTemplates = templates.filter(template => template.templateName?.includes('준법서약') && template.templateName?.includes('영문') || template.templateName?.includes('gtc') ) // GTC 템플릿 중 최신 리비전의 것만 선택 const gtcTemplates = foreignTemplates.filter(t => t.templateName?.includes('gtc')) const nonGtcTemplates = foreignTemplates.filter(t => !t.templateName?.includes('gtc')) if (gtcTemplates.length > 0) { // GTC 템플릿 중 이름이 가장 긴 것 (프로젝트 GTC 우선) 선택 const selectedGtcTemplate = gtcTemplates.reduce((prev, current) => (prev.templateName?.length || 0) > (current.templateName?.length || 0) ? prev : current ) setSelectedTemplateIds([...nonGtcTemplates.map(t => t.id), selectedGtcTemplate.id]) } else { setSelectedTemplateIds(nonGtcTemplates.map(t => t.id)) } } else if (isAllDomestic) { // 내자: 준법서약 (영문), GTC 제외한 모든 템플릿 선택 const domesticTemplates = templates.filter(template => { const name = template.templateName?.toLowerCase() || '' return !(name.includes('준법서약') && name.includes('영문')) && !name.includes('gtc') }) setSelectedTemplateIds(domesticTemplates.map(t => t.id)) } } }) .catch(() => toast.error("기본계약서 템플릿 로딩 실패")) .finally(() => setIsLoadingTemplates(false)) }, [vendors]) React.useEffect(() => { if (!props.open) { setSelectedTemplateIds([]) setNdaAttachments([]) setIsUploadingNdaFiles(false) setProgressValue(0) setCurrentStep("") setShowProgress(false) } }, [props.open]) // 비밀유지 계약서 첨부파일 추가 함수 const handleAddNdaAttachment = (event: React.ChangeEvent) => { const files = event.target.files if (files) { const newFiles = Array.from(files) setNdaAttachments(prev => [...prev, ...newFiles]) } // input 초기화 event.target.value = '' } // 비밀유지 계약서 첨부파일 제거 함수 const handleRemoveNdaAttachment = (fileIndex: number) => { setNdaAttachments(prev => prev.filter((_, index) => index !== fileIndex)) } // 비밀유지 계약서가 선택되었는지 확인하는 함수 const isNdaTemplateSelected = () => { return basicContractTemplates.some(template => selectedTemplateIds.includes(template.id) && template.templateName?.includes("비밀유지") ) } const onSendContracts = () => { if (selectedTemplateIds.length === 0) { return toast.error("최소 하나의 기본계약서 템플릿을 선택하세요.") } if (!session?.user?.id) return toast.error("인증 실패") // GTC 템플릿 선택 검증 const selectedGtcTemplates = basicContractTemplates.filter(template => selectedTemplateIds.includes(template.id) && template.templateName?.toLowerCase().includes('gtc') ) if (selectedGtcTemplates.length > 1) { return toast.error("GTC 템플릿은 하나만 선택할 수 있습니다.") } // 프로그레스 바를 즉시 표시 setShowProgress(true) setProgressValue(0) setCurrentStep("시작 중...") startProcessingTransition(async () => { try { // 전체 단계 수 계산 const gtcTemplates = basicContractTemplates.filter(template => selectedTemplateIds.includes(template.id) && template.templateName?.toLowerCase().includes('gtc') ) const totalSteps = (selectedTemplateIds.length > 0 ? 1 : 0) + (isNdaTemplateSelected() && ndaAttachments.length > 0 ? 1 : 0) + (gtcTemplates.length > 0 ? 1 : 0) let completedSteps = 0 // 1단계: 기본계약서 템플릿이 선택된 경우 백그라운드에서 처리 if (selectedTemplateIds.length > 0) { const templates = basicContractTemplates.filter(t => selectedTemplateIds.includes(t.id) ) setCurrentStep(`기본계약서 생성 중... (${templates.length}개 템플릿)`) console.log("📋 기본계약서 백그라운드 처리 시작", templates.length, "개 템플릿") await processBasicContractsInBackground(templates, vendors) completedSteps++ setProgressValue((completedSteps / totalSteps) * 100) } // 2단계: 비밀유지 계약서 첨부파일이 있는 경우 저장 if (isNdaTemplateSelected() && ndaAttachments.length > 0) { setCurrentStep(`비밀유지 계약서 첨부파일 저장 중... (${ndaAttachments.length}개 파일)`) console.log("📎 비밀유지 계약서 첨부파일 처리 시작", ndaAttachments.length, "개 파일") const ndaResult = await saveNdaAttachments({ vendorIds: vendors.map((v) => v.id), files: ndaAttachments, userId: session.user.id.toString() }) if (ndaResult.success) { toast.success(`비밀유지 계약서 첨부파일이 모두 저장되었습니다 (${ndaResult.summary?.success}/${ndaResult.summary?.total})`) } else { toast.error(`첨부파일 처리 중 일부 오류가 발생했습니다: ${ndaResult.error}`) } completedSteps++ setProgressValue((completedSteps / totalSteps) * 100) } // 3단계: GTC 템플릿 처리 if (selectedGtcTemplates.length > 0) { setCurrentStep(`GTC 문서 생성 중... (${selectedGtcTemplates.length}개 템플릿)`) console.log("📋 GTC 문서 생성 시작", selectedGtcTemplates.length, "개 템플릿") try { await processGtcTemplates(selectedGtcTemplates, vendors) completedSteps++ setProgressValue((completedSteps / totalSteps) * 100) } catch (error) { console.error("GTC 템플릿 처리 중 오류:", error) toast.error(`GTC 템플릿 처리 중 오류가 발생했습니다`) // GTC 처리 실패해도 전체 프로세스는 성공으로 간주 completedSteps++ setProgressValue((completedSteps / totalSteps) * 100) } } setCurrentStep("완료!") setProgressValue(100) toast.success("기본계약서가 성공적으로 발송되었습니다") // 잠시 완료 상태를 보여준 후 다이얼로그 닫기 setTimeout(() => { setShowProgress(false) props.onOpenChange?.(false) onSuccess?.() }, 1000) } catch (error) { console.error('기본계약서 발송 오류:', error) setShowProgress(false) toast.error(`처리 중 오류가 발생했습니다: ${error instanceof Error ? error.message : '알 수 없는 오류'}`) } }) } // 백그라운드에서 기본계약서 처리 const processBasicContractsInBackground = async (templates: BasicContractTemplate[], vendors: any[]) => { if (!session?.user?.id) { toast.error("인증 정보가 없습니다") return } try { const totalContracts = templates.length * vendors.length let processedCount = 0 // 각 벤더별로, 각 템플릿을 처리 for (let vendorIndex = 0; vendorIndex < vendors.length; vendorIndex++) { const vendor = vendors[vendorIndex] // 벤더별 템플릿 데이터 생성 (한글 변수명 사용) const templateData = { company_name: vendor.vendorName || '협력업체명', company_address: vendor.address || '주소', representative_name: vendor.representativeName || '대표자명', signature_date: new Date().toLocaleDateString('ko-KR'), tax_id: vendor.taxId || '사업자번호', phone_number: vendor.phone || '전화번호', } console.log(`🔄 벤더 ${vendorIndex + 1}/${vendors.length} 템플릿 데이터:`, templateData) // 해당 벤더에 대해 각 템플릿을 순차적으로 처리 for (let templateIndex = 0; templateIndex < templates.length; templateIndex++) { const template = templates[templateIndex] processedCount++ // 진행률 업데이트 const contractProgress = (processedCount / totalContracts) * 100 setProgressValue(contractProgress) setCurrentStep(`기본계약서 생성 중... (${processedCount}/${totalContracts})`) console.log(`📄 처리 중: ${vendor.vendorName} - ${template.templateName} (${processedCount}/${totalContracts})`) // 개별 벤더에 대한 기본계약 생성 await processTemplate(template, templateData, [vendor]) console.log(`✅ 완료: ${vendor.vendorName} - ${template.templateName}`) } } toast.success(`총 ${totalContracts}개 기본계약이 모두 생성되었습니다`) router.refresh(); } catch (error) { console.error('기본계약 처리 중 오류:', error) toast.error(`기본계약 처리 중 오류가 발생했습니다: ${error instanceof Error ? error.message : '알 수 없는 오류'}`) } } const processTemplate = async (template: BasicContractTemplate, templateData: any, vendors: any[]) => { try { // 1. 템플릿 파일 가져오기 const templateResponse = await fetch('/api/basic-contract/get-template', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ templateId: template.id }) }) if (!templateResponse.ok) { throw new Error(`템플릿 파일을 가져올 수 없습니다: ${template.templateName}`) } const templateBlob = await templateResponse.blob() // 2. PDFTron을 사용해서 변수 치환 및 PDF 변환 // @ts-ignore const WebViewer = await import("@pdftron/webviewer").then(({ default: WebViewer }) => WebViewer) // 임시 WebViewer 인스턴스 생성 (DOM에 추가하지 않음) const tempDiv = document.createElement('div') tempDiv.style.display = 'none' document.body.appendChild(tempDiv) const instance = await WebViewer( { path: "/pdftronWeb", licenseKey: process.env.NEXT_PUBLIC_PDFTRON_WEBVIEW_KEY, fullAPI: true, }, tempDiv ) try { const { Core } = instance const { createDocument } = Core // 3. 템플릿 문서 생성 및 변수 치환 const templateDoc = await createDocument(templateBlob, { filename: template.fileName || 'template.docx', extension: 'docx', }) console.log("🔄 변수 치환 시작:", templateData) await templateDoc.applyTemplateValues(templateData) console.log("✅ 변수 치환 완료") // 4. PDF 변환 const fileData = await templateDoc.getFileData() const pdfBuffer = await Core.officeToPDFBuffer(fileData, { extension: 'docx' }) console.log(`✅ PDF 변환 완료: ${template.templateName}`, `크기: ${pdfBuffer.byteLength} bytes`) // 5. 기본계약 생성 요청 const { error: contractError } = await requestBasicContractInfo({ vendorIds: vendors.map((v) => v.id), requestedBy: Number(session!.user.id), templateId: template.id, pdfBuffer: new Uint8Array(pdfBuffer), }) if (contractError) { throw new Error(contractError) } console.log(`✅ 기본계약 생성 완료: ${template.templateName}`) } finally { // 임시 WebViewer 정리 instance.UI.dispose() document.body.removeChild(tempDiv) } } catch (error) { console.error(`❌ 템플릿 처리 실패: ${template.templateName}`, error) throw error } } // GTC 템플릿 처리 함수 const processGtcTemplates = async (gtcTemplates: BasicContractTemplate[], vendors: any[]) => { if (!session?.user?.id) { toast.error("인증 정보가 없습니다") return } try { const vendorIds = vendors.map(v => v.id) const userId = Number(session.user.id) for (const template of gtcTemplates) { const templateName = template.templateName?.toLowerCase() || '' if (templateName.includes('general gtc') || (templateName.includes('gtc') && !templateName.includes(' '))) { // General GTC 처리 console.log(`📄 General GTC 템플릿 처리: ${template.templateName}`) const gtcDocument = await getStandardGtcDocumentId() if (!gtcDocument) { toast.error(`표준 GTC 문서를 찾을 수 없습니다.`) continue } const result = await createGtcVendorDocuments({ baseDocumentId: gtcDocument.id, vendorIds, createdById: userId, documentTitle: gtcDocument.title }) if (result.success) { console.log(`✅ General GTC 문서 생성 완료: ${result.count}개`) } else { toast.error(`General GTC 문서 생성 실패: ${result.error}`) } } else if (templateName.includes('gtc') && templateName.includes(' ')) { // 프로젝트 GTC 처리 (프로젝트 코드 추출) const projectCodeMatch = template.templateName?.match(/^([A-Z0-9]+)\s+GTC/) console.log("🔄 프로젝트 GTC 템플릿 처리: ", template.templateName, projectCodeMatch) console.log(` - 템플릿 이름 분석: "${template.templateName}"`) console.log(` - 소문자 변환: "${templateName}"`) if (projectCodeMatch) { const projectCode = projectCodeMatch[1] console.log(`📄 프로젝트 GTC 템플릿 처리: ${template.templateName} (프로젝트: ${projectCode})`) const result = await createProjectGtcVendorDocuments({ projectCode, vendorIds, createdById: userId, documentTitle: template.templateName }) if (result.success) { console.log(`✅ 프로젝트 GTC 문서 생성 완료: ${result.count}개`) } else { toast.error(`프로젝트 GTC 문서 생성 실패: ${result.error}`) } } else { toast.error(`프로젝트 GTC 템플릿 이름 형식이 올바르지 않습니다: ${template.templateName}`) } } else { console.log(`⚠️ GTC 템플릿이지만 처리할 수 없는 형식: ${template.templateName}`) } } toast.success(`GTC 문서가 ${gtcTemplates.length}개 템플릿에 대해 생성되었습니다`) } catch (error) { console.error('GTC 템플릿 처리 중 오류:', error) toast.error(`GTC 템플릿 처리 중 오류가 발생했습니다: ${error instanceof Error ? error.message : '알 수 없는 오류'}`) } } const dialogContent = (
{/* 선택된 협력업체 정보 */}
{vendors.map((vendor) => (
{vendor.vendorName}
{vendor.vendorCode} • {vendor.email || "이메일 없음"}
))}
{/* 기본계약서 템플릿 선택 (다중 선택) */}
{isLoadingTemplates ? (
템플릿 로딩 중...
) : (
{basicContractTemplates.map((template) => (
{ if (checked) { setSelectedTemplateIds(prev => [...prev, template.id]) } else { setSelectedTemplateIds(prev => prev.filter(id => id !== template.id)) } }} />
))} {basicContractTemplates.length === 0 && (
사용 가능한 템플릿이 없습니다.
)}
)} {selectedTemplateIds.length > 0 && (
{selectedTemplateIds.length}개 템플릿이 선택되었습니다. {vendors.length > 0 && vendors.every(v => v.country !== 'KR') && " (외자 벤더 - 자동 선택됨)"} {vendors.length > 0 && vendors.every(v => v.country === 'KR') && " (내자 벤더 - 자동 선택됨)"}
)}
{/* 비밀유지 계약서 첨부파일 */} {isNdaTemplateSelected() && (
{/* 선택된 파일들 표시 */} {ndaAttachments.length > 0 && (
선택된 파일 ({ndaAttachments.length}개)
{ndaAttachments.map((file, index) => (
{file.name} ({(file.size / 1024 / 1024).toFixed(2)} MB)
))}
)} {/* 파일 선택 버튼 */}
{isUploadingNdaFiles && (
파일 업로드 중...
)}
비밀유지 계약서와 관련된 첨부파일을 업로드하세요. 각 벤더별로 동일한 파일이 저장됩니다.
)}
) if (isDesktop) { return ( {showTrigger && ( )} {title} {vendors.length} {vendors.length === 1 ? "개 협력업체" : "개 협력업체들"}에게 {description}
{dialogContent}
{/* 프로그레스 바 */} {(showProgress || isProcessing) && (
{currentStep || "처리 중..."} {Math.round(progressValue)}%
)} {/* 버튼들 */}
) } return ( {showTrigger && ( )} {title} {vendors.length} {vendors.length === 1 ? "개 협력업체" : "개 협력업체들"}에게 {description}
{dialogContent}
{/* 프로그레스 바 */} {(showProgress || isProcessing) && (
{currentStep || "처리 중..."} {Math.round(progressValue)}%
)} {/* 버튼들 */}
) }