From 522176a23ad9db47f85ceed13b2e54d369aa6e0a Mon Sep 17 00:00:00 2001 From: dujinkim Date: Wed, 3 Sep 2025 10:36:27 +0000 Subject: (최겸) 견적/입찰 내 기본계약 전달 컴포넌트 개발(word to pdf) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- components/basic-contract/send-contract-dialog.tsx | 709 +++++++++++++++++++++ 1 file changed, 709 insertions(+) create mode 100644 components/basic-contract/send-contract-dialog.tsx (limited to 'components') diff --git a/components/basic-contract/send-contract-dialog.tsx b/components/basic-contract/send-contract-dialog.tsx new file mode 100644 index 00000000..511011b0 --- /dev/null +++ b/components/basic-contract/send-contract-dialog.tsx @@ -0,0 +1,709 @@ +"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)}% +
+ +
+ )} + + {/* 버튼들 */} +
+ + + + +
+
+
+
+ ) +} -- cgit v1.2.3