"use client"; import React, { useState, useEffect, useRef, SetStateAction, Dispatch, } from "react"; import { WebViewerInstance } from "@pdftron/webviewer"; import { Loader2, FileText, ClipboardList, AlertTriangle, FileSignature, Target, CheckCircle2 } from "lucide-react"; import { toast } from "sonner"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter, } from "@/components/ui/dialog"; import { Label } from "@/components/ui/label"; import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group"; import { Checkbox } from "@/components/ui/checkbox"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; import { Input } from "@/components/ui/input"; import { Textarea } from "@/components/ui/textarea"; import { Upload } from "lucide-react"; interface FileInfo { path: string; name: string; type: 'main' | 'attachment' | 'survey'; } interface BasicContractSignViewerProps { contractId?: number; filePath?: string; additionalFiles?: FileInfo[]; templateName?: string; isOpen?: boolean; onClose?: () => void; onSign?: (documentData: ArrayBuffer, surveyData?: any) => Promise; instance: WebViewerInstance | null; setInstance: Dispatch>; t?: (key: string) => string; } // ✅ 자동 서명 필드 생성을 위한 타입 정의 interface SignaturePattern { regex: RegExp; name: string; priority: number; offsetX?: number; offsetY?: number; width?: number; height?: number; } interface DetectedSignatureLocation { pageIndex: number; text: string; rect: { x1: number; y1: number; x2: number; y2: number; }; pattern: SignaturePattern; confidence: number; } // ✅ 개선된 자동 서명 필드 감지 클래스 // ✅ 초간단 안전한 서명 필드 감지 클래스 (새로고침 제거) class AutoSignatureFieldDetector { private instance: WebViewerInstance; private signaturePatterns: SignaturePattern[]; constructor(instance: WebViewerInstance) { this.instance = instance; this.signaturePatterns = this.initializePatterns(); } private initializePatterns(): SignaturePattern[] { return [ // 한국어 패턴들 { regex: /서명\s*[::]\s*[_\-\s]{3,}/gi, name: "한국어_서명_콜론", priority: 10, offsetX: 80, offsetY: -5, width: 150, height: 40 }, { regex: /서명란\s*[_\-\s]{0,}/gi, name: "한국어_서명란", priority: 9, offsetX: 60, offsetY: -5, width: 150, height: 40 }, // 영어 패턴들 { regex: /signature\s*[::]\s*[_\-\s]{3,}/gi, name: "영어_signature_콜론", priority: 8, offsetX: 120, offsetY: -5, width: 150, height: 40 }, { regex: /sign\s+here\s*[::]?\s*[_\-\s]{0,}/gi, name: "영어_sign_here", priority: 9, offsetX: 100, offsetY: -5, width: 150, height: 40 } ]; } async detectAndCreateSignatureFields(): Promise { console.log("🔍 안전한 서명 필드 감지 시작..."); try { // ✅ 1단계: 기본 유효성 검사만 if (!this.instance?.Core?.documentViewer) { throw new Error("WebViewer 인스턴스가 유효하지 않습니다."); } const { Core } = this.instance; const { documentViewer } = Core; // ✅ 2단계: 문서 존재 확인만 (getPDFDoc 호출 안함) const document = documentViewer.getDocument(); if (!document) { throw new Error("PDF 문서가 로드되지 않았습니다."); } console.log("📄 문서 확인 완료, 기존 필드 검사..."); // ✅ 3단계: 기존 서명 필드 확인 (안전한 방법) const existingFields = await this.checkExistingFieldsSafely(); if (existingFields.length > 0) { console.log(`✅ 기존 서명 필드 발견: ${existingFields.length}개`); return existingFields; } // ✅ 4단계: 단순 기본 서명 필드 생성 (텍스트 분석 스킵) console.log("📝 기본 서명 필드 생성..."); const defaultField = await this.createSimpleSignatureField(); // ✅ 5단계: 새로고침 없이 완료 console.log("✅ 서명 필드 생성 완료 (새로고침 스킵)"); return [defaultField]; } catch (error) { console.error("📛 안전한 서명 필드 생성 실패:", error); // 에러 타입별 메시지 let errorMessage = "서명 필드 생성에 실패했습니다."; if (error instanceof Error) { if (error.message.includes("인스턴스")) { errorMessage = "뷰어가 준비되지 않았습니다."; } else if (error.message.includes("문서")) { errorMessage = "문서를 불러오는 중입니다."; } } throw new Error(errorMessage); } } // ✅ 안전한 기존 필드 확인 (PDFDoc 접근 안함) private async checkExistingFieldsSafely(): Promise { try { const { annotationManager } = this.instance.Core; const annotations = annotationManager.getAnnotationsList(); const signatureFields: string[] = []; for (const annotation of annotations) { try { if (annotation.getCustomData && annotation.getCustomData('fieldName')) { const fieldName = annotation.getCustomData('fieldName'); if (fieldName.includes('signature') || fieldName.includes('서명')) { signatureFields.push(fieldName); } } } catch (annotError) { // 개별 어노테이션 에러 무시 continue; } } return signatureFields; } catch (error) { console.warn("기존 필드 확인 실패 (무시):", error); return []; } } // ✅ 초간단 서명 필드 생성 (복잡한 텍스트 분석 없이) private async createSimpleSignatureField(): Promise { try { const { Core, UI } = this.instance; const { documentViewer, annotationManager, Annotations } = Core; // 페이지 정보 안전하게 가져오기 const pageCount = documentViewer.getPageCount(); const lastPageIndex = Math.max(0, pageCount - 1); // 페이지 크기 안전하게 가져오기 const pageWidth = documentViewer.getPageWidth(pageCount) || 612; const pageHeight = documentViewer.getPageHeight(pageCount) || 792; console.log(`📏 페이지 정보: ${pageCount}페이지, 크기 ${pageWidth}x${pageHeight}`); // ✅ 간단한 서명 어노테이션 생성 (PDFDoc 접근 없이) const fieldName = `simple_signature_${Date.now()}`; // 서명 위젯 어노테이션 생성 const signatureWidget = new Annotations.SignatureWidgetAnnotation({ appearance: Annotations.SignatureWidgetAnnotation.DefaultAppearance.MATERIAL_OUTLINE, Width: 150, Height: 50 }); // 위치 설정 (마지막 페이지 하단) signatureWidget.setPageNumber(pageCount); signatureWidget.setX(pageWidth * 0.3); signatureWidget.setY(pageHeight * 0.15); signatureWidget.setWidth(150); signatureWidget.setHeight(50); // 필드명 설정 signatureWidget.setFieldName(fieldName); signatureWidget.setCustomData('fieldName', fieldName); // 스타일 설정 signatureWidget.StrokeColor = new Annotations.Color(0, 100, 200); // 파란색 signatureWidget.StrokeThickness = 2; // 어노테이션 추가 annotationManager.addAnnotation(signatureWidget); annotationManager.redrawAnnotation(signatureWidget); console.log(`✅ 간단 서명 필드 생성: ${fieldName}`); return fieldName; } catch (error) { console.error("📛 간단 서명 필드 생성 실패:", error); // ✅ 최후의 수단: 텍스트 어노테이션으로 안내 return await this.createTextGuidance(); } } // ✅ 최후의 수단: 텍스트 안내 생성 private async createTextGuidance(): Promise { try { const { Core } = this.instance; const { documentViewer, annotationManager, Annotations } = Core; const pageCount = documentViewer.getPageCount(); const pageWidth = documentViewer.getPageWidth(pageCount) || 612; const pageHeight = documentViewer.getPageHeight(pageCount) || 792; // 텍스트 어노테이션으로 서명 안내 const textAnnot = new Annotations.FreeTextAnnotation(); textAnnot.setPageNumber(pageCount); textAnnot.setX(pageWidth * 0.25); textAnnot.setY(pageHeight * 0.1); textAnnot.setWidth(pageWidth * 0.5); textAnnot.setHeight(60); textAnnot.setContents("👆 여기를 클릭하여 서명해주세요"); textAnnot.FontSize = '14pt'; textAnnot.TextColor = new Annotations.Color(255, 0, 0); // 빨간색 textAnnot.StrokeColor = new Annotations.Color(255, 200, 200); textAnnot.FillColor = new Annotations.Color(255, 240, 240); const fieldName = `text_guidance_${Date.now()}`; textAnnot.setCustomData('fieldName', fieldName); annotationManager.addAnnotation(textAnnot); annotationManager.redrawAnnotation(textAnnot); console.log(`✅ 텍스트 안내 생성: ${fieldName}`); return fieldName; } catch (error) { console.error("📛 텍스트 안내 생성도 실패:", error); return "manual_signature_required"; } } } function useAutoSignatureFields(instance: WebViewerInstance | null) { const [signatureFields, setSignatureFields] = useState([]); const [isProcessing, setIsProcessing] = useState(false); const [error, setError] = useState(null); // 중복 실행 방지 const processingRef = useRef(false); const timeoutRef = useRef(null); useEffect(() => { if (!instance) return; const { documentViewer } = instance.Core; const handleDocumentLoaded = () => { // ✅ 중복 실행 방지 if (processingRef.current) { console.log("📛 이미 처리 중이므로 스킵"); return; } // ✅ 기존 타이머 정리 if (timeoutRef.current) { clearTimeout(timeoutRef.current); timeoutRef.current = null; } // ✅ 짧은 지연 후 실행 (3초) timeoutRef.current = setTimeout(async () => { if (processingRef.current) return; processingRef.current = true; setIsProcessing(true); setError(null); try { console.log("📄 문서 로드 완료, 안전한 서명 필드 처리 시작..."); // ✅ 최종 유효성 검사 if (!instance?.Core?.documentViewer?.getDocument()) { throw new Error("문서가 준비되지 않았습니다."); } const detector = new AutoSignatureFieldDetector(instance); const fields = await detector.detectAndCreateSignatureFields(); setSignatureFields(fields); // ✅ 결과에 따른 토스트 메시지 if (fields.length > 0) { const hasSimpleField = fields.some(field => field.startsWith('simple_signature_')); const hasTextGuidance = fields.some(field => field.startsWith('text_guidance_')); const hasManualRequired = fields.includes('manual_signature_required'); if (hasSimpleField) { toast.success("📝 서명 필드가 생성되었습니다.", { description: "마지막 페이지 하단의 파란색 영역에서 서명해주세요.", icon: , duration: 5000 }); } else if (hasTextGuidance) { toast.success("📍 서명 안내가 표시되었습니다.", { description: "빨간색 텍스트 영역을 클릭하여 서명해주세요.", icon: , duration: 6000 }); } else if (hasManualRequired) { toast.info("수동 서명이 필요합니다.", { description: "문서에서 서명할 위치를 직접 클릭해주세요.", icon: , duration: 5000 }); } else { toast.success(`📋 ${fields.length}개의 서명 필드를 확인했습니다.`, { description: "기존 서명 필드가 발견되었습니다.", icon: , duration: 4000 }); } } else { toast.info("서명 필드 준비 중", { description: "문서에서 서명할 위치를 클릭해주세요.", icon: , duration: 4000 }); } } catch (error) { console.error("📛 안전한 서명 필드 처리 실패:", error); const errorMessage = error instanceof Error ? error.message : "서명 필드 처리에 실패했습니다."; setError(errorMessage); // ✅ 부드러운 에러 처리 if (errorMessage.includes("준비")) { toast.info("문서 로딩 중", { description: "잠시 후 다시 시도하거나 수동으로 서명해주세요.", icon: }); } else { toast.info("수동 서명 모드", { description: "문서에서 서명할 위치를 직접 클릭해주세요.", icon: }); } } finally { setIsProcessing(false); processingRef.current = false; } }, 3000); // 3초 지연 }; // ✅ 이벤트 리스너 등록 documentViewer.removeEventListener('documentLoaded', handleDocumentLoaded); documentViewer.addEventListener('documentLoaded', handleDocumentLoaded); return () => { documentViewer.removeEventListener('documentLoaded', handleDocumentLoaded); if (timeoutRef.current) { clearTimeout(timeoutRef.current); timeoutRef.current = null; } processingRef.current = false; }; }, [instance]); // ✅ 컴포넌트 언마운트 시 정리 useEffect(() => { return () => { if (timeoutRef.current) { clearTimeout(timeoutRef.current); } processingRef.current = false; }; }, []); return { signatureFields, isProcessing, hasSignatureFields: signatureFields.length > 0, error }; } export function BasicContractSignViewer({ contractId, filePath, additionalFiles = [], templateName = "", isOpen = false, onClose, onSign, instance, setInstance, t = (key: string) => key, }: BasicContractSignViewerProps) { console.log("🔍 BasicContractSignViewer props:", { contractId, filePath, additionalFiles, templateName, isNDATemplate: templateName.includes('비밀유지') || templateName.includes('NDA') }); const [fileLoading, setFileLoading] = useState(true); const [activeTab, setActiveTab] = useState("main"); const [surveyData, setSurveyData] = useState({}); const [surveyAnswers, setSurveyAnswers] = useState>({}); const [surveyTemplate, setSurveyTemplate] = useState(null); const [surveyLoading, setSurveyLoading] = useState(false); const [uploadedFiles, setUploadedFiles] = useState>({}); const [isInitialLoaded, setIsInitialLoaded] = useState(false); const viewer = useRef(null); const initialized = useRef(false); const isCancelled = useRef(false); const currentDocumentPath = useRef(""); const [showDialog, setShowDialog] = useState(isOpen); const webViewerInstance = useRef(null); // ✅ 자동 서명 필드 생성 훅 사용 const { signatureFields, isProcessing: isAutoSignProcessing, hasSignatureFields, error: autoSignError } = useAutoSignatureFields(webViewerInstance.current || instance); // 템플릿 타입 판단 const isComplianceTemplate = templateName.includes('준법'); const isNDATemplate = templateName.includes('비밀유지') || templateName.includes('NDA'); // 파일 목록 생성 const allFiles: FileInfo[] = React.useMemo(() => { const files: FileInfo[] = []; if (filePath) { files.push({ path: filePath, name: templateName || "기본 계약서", type: "main", }); } const normalizedAttachments: FileInfo[] = (additionalFiles || []) .map((f: any, idx: number) => ({ path: f.path ?? f.filePath ?? "", name: `첨부파일 ${idx + 1}`, type: "attachment" as const, })) .filter(f => !!f.path); files.push(...normalizedAttachments); if (isComplianceTemplate) { files.push({ path: "", name: "준법 설문조사", type: "survey", }); } console.log("📂 생성된 allFiles:", files, { isNDATemplate, isComplianceTemplate }); return files; }, [filePath, additionalFiles, templateName, isComplianceTemplate, isNDATemplate]); // WebViewer 정리 함수 const cleanupWebViewer = () => { console.log("🧹 WebViewer 정리 시작"); if (webViewerInstance.current) { try { const { documentViewer } = webViewerInstance.current.Core; if (documentViewer && documentViewer.getDocument()) { documentViewer.closeDocument(); } if (webViewerInstance.current.UI && typeof webViewerInstance.current.UI.dispose === 'function') { webViewerInstance.current.UI.dispose(); } } catch (error) { console.warn("WebViewer 정리 중 에러 (무시됨):", error); } webViewerInstance.current = null; } if (instance && setInstance) { setInstance(null); } setTimeout(() => cleanupHtmlStyle(), 100); }; // 다이얼로그 및 파일 상태 변경 시 리셋 useEffect(() => { setShowDialog(isOpen); if (isOpen && isComplianceTemplate && !surveyTemplate) { loadSurveyTemplate(); } if (isOpen) { setIsInitialLoaded(false); currentDocumentPath.current = ""; console.log("🔄 새로운 계약서 열림, 상태 리셋"); } }, [isOpen, isComplianceTemplate]); // filePath 변경 시 상태 리셋 및 즉시 문서 로드 useEffect(() => { if (!filePath) return; console.log("🔄 filePath 변경으로 상태 리셋 및 문서 로드:", filePath); setIsInitialLoaded(false); currentDocumentPath.current = ""; setActiveTab("main"); const currentInstance = webViewerInstance.current || instance; if (currentInstance) { const normalizedPath = filePath.startsWith('/') ? filePath.substring(1) : filePath; const encodedPath = normalizedPath.split('/').map(part => encodeURIComponent(part)).join('/'); const apiFilePath = `/api/files/${encodedPath}`; console.log("📄 filePath 변경으로 즉시 문서 로드:", apiFilePath); loadDocument(currentInstance, apiFilePath, true).then(() => { setIsInitialLoaded(true); console.log("✅ filePath 변경 문서 로드 완료"); }).catch((error) => { console.error("📛 filePath 변경 문서 로드 실패:", error); }); } }, [filePath, instance]); const loadSurveyTemplate = async () => { setSurveyLoading(true); const mockTemplate = { id: 1, name: '기본 준법 설문조사', description: '모든 계약업체 대상 기본 준법 설문조사', questions: [ { id: 4, questionNumber: '4', questionText: '귀사의 법률적 조직형태는?', questionType: 'DROPDOWN', isRequired: true, hasDetailText: false, hasFileUpload: false, options: [ { id: 1, optionValue: 'COMPANY_CORP', optionText: '주식회사/유한회사' }, { id: 2, optionValue: 'INDIVIDUAL', optionText: '개인회사' }, { id: 3, optionValue: 'PARTNERSHIP', optionText: '조합' }, { id: 4, optionValue: 'JOINT_VENTURE', optionText: '조인트벤처' }, { id: 5, optionValue: 'OTHER', optionText: '기타', allowsOtherInput: true }, ] }, { id: 6, questionNumber: '6', questionText: '부패방지와 관련한 귀사의 준법정책이 있습니까? 있다면 첨부파일로 제공하여 주시기 바랍니다.', questionType: 'RADIO', isRequired: true, hasDetailText: false, hasFileUpload: true, options: [ { id: 6, optionValue: 'YES', optionText: '네' }, { id: 7, optionValue: 'NO', optionText: '아니오' }, ] }, { id: 11, questionNumber: '11', questionText: '귀사의 사주, 임원 중에서 전(최근 3년내)·현직 공직자인 사람이 있습니까? 만약 있다면 상세하게 기술해 주십시오.', questionType: 'RADIO', isRequired: true, hasDetailText: true, hasFileUpload: false, options: [ { id: 11, optionValue: 'YES', optionText: '네' }, { id: 12, optionValue: 'NO', optionText: '아니오' }, ] }, ] }; setSurveyTemplate(mockTemplate); setSurveyLoading(false); }; // WebViewer 초기화 개선 useEffect(() => { if (!initialized.current && viewer.current) { initialized.current = true; isCancelled.current = false; const initializeWebViewer = () => { if (!viewer.current || isCancelled.current) { console.log("📛 WebViewer 초기화 취소됨 (DOM 없음)"); return; } const viewerElement = viewer.current; if (!viewerElement.isConnected) { console.log("📛 WebViewer DOM이 연결되지 않음, 재시도..."); setTimeout(initializeWebViewer, 100); return; } cleanupWebViewer(); console.log("📄 WebViewer 초기화 시작..."); import("@pdftron/webviewer").then(({ default: WebViewer }) => { if (isCancelled.current || !viewer.current) { console.log("📛 WebViewer 초기화 취소됨 (import 후)"); return; } WebViewer( { path: "/pdftronWeb", licenseKey: process.env.NEXT_PUBLIC_PDFTRON_WEBVIEW_KEY, fullAPI: true }, viewerElement ).then((newInstance) => { if (isCancelled.current) { console.log("📛 WebViewer 인스턴스 생성 후 취소됨"); return; } console.log("📄 WebViewer 초기화 완료"); webViewerInstance.current = newInstance; setInstance(newInstance); setFileLoading(false); const { documentViewer } = newInstance.Core; const FitMode = newInstance.UI.FitMode; // 문서 로드 완료 시 처리 const handleDocumentLoaded = () => { setFileLoading(false); newInstance.UI.setFitMode(FitMode.FitWidth); requestAnimationFrame(() => { try { documentViewer.refreshAll(); documentViewer.updateView(); window.dispatchEvent(new Event("resize")); setTimeout(() => window.dispatchEvent(new Event("resize")), 100); } catch (e) { console.warn("layout refresh skipped", e); } }); }; documentViewer.addEventListener('documentLoaded', handleDocumentLoaded); documentViewer.addEventListener('layoutChanged', () => { if (newInstance.UI.getFitMode && newInstance.UI.getFitMode() !== FitMode.Zoom) { newInstance.UI.setFitMode(FitMode.Zoom); } }); newInstance.UI.setMinZoomLevel('25%'); newInstance.UI.setMaxZoomLevel('400%'); documentViewer.addEventListener('documentLoadingError', (error) => { console.error("📛 WebViewer 문서 로딩 에러:", error); let showToast = true; let errorMessage = "문서를 불러오는데 실패했습니다."; if (error && typeof error === 'object') { const errorStr = JSON.stringify(error).toLowerCase(); if (errorStr.includes('linearized') || errorStr.includes('getreference')) { console.warn("⚠️ PDF 구조 경고 (문서 로드는 진행됨)"); showToast = false; } else if (errorStr.includes('network')) { errorMessage = "네트워크 연결을 확인해주세요."; } else if (errorStr.includes('permission')) { errorMessage = "문서에 접근할 권한이 없습니다."; } } if (showToast) { setFileLoading(false); toast.error(errorMessage); } }); }).catch((error) => { console.error("📛 WebViewer 초기화 실패:", error); setFileLoading(false); toast.error("뷰어 초기화에 실패했습니다."); }); }).catch((error) => { console.error("📛 WebViewer 모듈 로드 실패:", error); setFileLoading(false); toast.error("뷰어 모듈을 불러오는데 실패했습니다."); }); }; requestAnimationFrame(() => { setTimeout(initializeWebViewer, 50); }); } return () => { isCancelled.current = true; cleanupWebViewer(); }; }, [setInstance]); // 확장자 추출 유틸 const getExtFromPath = (p: string) => { const m = p.toLowerCase().match(/\.([a-z0-9]+)(?:\?.*)?$/); return m ? m[1] : undefined; }; // 문서 로드 함수 개선 const loadDocument = async ( instance: WebViewerInstance, documentPath: string, forceReload = false ) => { if (!forceReload && currentDocumentPath.current === documentPath) { console.log("📄 동일한 문서이므로 스킵:", documentPath); return; } setFileLoading(true); try { console.log("📄 문서 로드 시작(UI):", documentPath, forceReload ? "(강제 리로드)" : ""); if (!instance || !instance.UI || !instance.Core) { throw new Error("WebViewer 인스턴스가 유효하지 않습니다."); } const ext = getExtFromPath(documentPath); await instance.UI.loadDocument(documentPath, { ...(ext ? { extension: ext } : {}), filename: documentPath.split("/").pop(), }); currentDocumentPath.current = documentPath; console.log("📄 문서 로드 완료(UI):", documentPath); const { documentViewer } = instance.Core; requestAnimationFrame(() => { try { documentViewer.refreshAll(); documentViewer.updateView(); window.dispatchEvent(new Event("resize")); setTimeout(() => window.dispatchEvent(new Event("resize")), 100); } catch (e) { console.warn("레이아웃 새로고침 스킵:", e); } }); } catch (error) { console.error("📛 문서 로딩 실패(UI):", error); currentDocumentPath.current = ""; let msg = "문서를 불러오는데 실패했습니다."; if (error instanceof Error) { const s = error.message.toLowerCase(); if (s.includes("network") || s.includes("fetch")) { msg = "네트워크 연결을 확인해주세요."; } else if (s.includes("permission") || s.includes("access")) { msg = "문서에 접근할 권한이 없습니다."; } else if (s.includes("corrupt") || s.includes("invalid")) { msg = "파일이 손상되었거나 형식이 올바르지 않습니다."; } else if (s.includes("linearized") || s.includes("getreference")) { msg = ""; } } if (msg) toast.error(msg); } finally { setFileLoading(false); } }; // 폼 데이터 수집 함수 const collectFormData = async (instance: WebViewerInstance) => { try { const { documentViewer, annotationManager } = instance.Core; const fieldManager = annotationManager.getFieldManager(); const fields = fieldManager.getFields(); const formData: any = {}; fields.forEach((field: any) => { formData[field.name] = field.value; }); console.log('📝 폼 데이터 수집:', formData); return formData; } catch (error) { console.error('📛 폼 데이터 수집 실패:', error); return {}; } }; // 탭 변경 핸들러 const handleTabChange = async (newTab: string) => { setActiveTab(newTab); if (newTab === "survey") return; const currentInstance = webViewerInstance.current || instance; if (!currentInstance || fileLoading) return; let targetFile: FileInfo | undefined; if (newTab === "main") { targetFile = allFiles.find(f => f.type === "main"); } else if (newTab.startsWith("file-")) { const fileIndex = parseInt(newTab.replace("file-", ""), 10); targetFile = allFiles.filter(f => f.type !== 'survey')[fileIndex]; } if (!targetFile?.path) { console.warn("📛 대상 파일을 찾을 수 없음:", newTab, allFiles); return; } const normalizedPath = targetFile.path.startsWith("/") ? targetFile.path.substring(1) : targetFile.path; const encodedPath = normalizedPath.split("/").map(encodeURIComponent).join("/"); const apiFilePath = `/api/files/${encodedPath}`; console.log("📄 탭 변경으로 문서 로드:", { newTab, targetFile, apiFilePath }); try { currentDocumentPath.current = ""; await loadDocument(currentInstance, apiFilePath, true); setIsInitialLoaded(true); const { documentViewer } = currentInstance.Core; requestAnimationFrame(() => { try { documentViewer.refreshAll(); documentViewer.updateView(); window.dispatchEvent(new Event("resize")); } catch (e) { console.warn("탭 변경 후 레이아웃 새로고침 스킵:", e); } }); } catch (e) { console.error("📛 탭 변경 실패:", e); } }; // 초기 메인 문서 로드 개선 useEffect(() => { console.log("🔍 초기 로드 체크:", { hasInstance: !!(webViewerInstance.current || instance), hasFilePath: !!filePath, activeTab, isInitialLoaded, allFilesLength: allFiles.length, isNDATemplate }); const currentInstance = webViewerInstance.current || instance; if (!currentInstance || !filePath || isInitialLoaded) { return; } const isMainTab = activeTab === 'main'; const shouldLoadInitial = allFiles.length === 1 || isMainTab; if (!shouldLoadInitial || currentDocumentPath.current !== "") { return; } const normalizedPath = filePath.startsWith('/') ? filePath.substring(1) : filePath; const encodedPath = normalizedPath.split('/').map(part => encodeURIComponent(part)).join('/'); const apiFilePath = `/api/files/${encodedPath}`; console.log("📄 초기 마운트 문서 로드:", { apiFilePath, isNDATemplate, activeTab }); currentDocumentPath.current = ""; loadDocument(currentInstance, apiFilePath, true).then(() => { setIsInitialLoaded(true); console.log("✅ 초기 마운트 로드 완료"); }).catch((error) => { console.error("📛 초기 마운트 로드 실패:", error); }); }, [webViewerInstance.current, instance, filePath, activeTab, isInitialLoaded, allFiles.length, isNDATemplate]); // 설문조사 답변 업데이트 함수 const updateSurveyAnswer = (questionId: number, field: string, value: any) => { setSurveyAnswers(prev => ({ ...prev, [questionId]: { ...prev[questionId], questionId, [field]: value } })); }; // 파일 업로드 핸들러 const handleSurveyFileUpload = (questionId: number, files: FileList | null) => { if (!files) return; const fileArray = Array.from(files); setUploadedFiles(prev => ({ ...prev, [questionId]: fileArray })); updateSurveyAnswer(questionId, 'files', fileArray); }; // 질문 완료 여부 체크 const isSurveyQuestionComplete = (question: any): boolean => { const answer = surveyAnswers[question.id]; if (!question.isRequired) return true; if (!answer?.answerValue) return false; if (question.hasDetailText && answer.answerValue === 'YES' && !answer.detailText) { return false; } if (question.hasFileUpload && answer.answerValue === 'YES' && (!answer.files || answer.files.length === 0)) { return false; } return true; }; // 전체 설문조사 완료 여부 체크 const isSurveyComplete = (): boolean => { if (!surveyTemplate?.questions) return false; return surveyTemplate.questions.every((question: any) => isSurveyQuestionComplete(question)); }; // 설문조사 데이터 처리 const handleSurveyComplete = async () => { if (!isSurveyComplete()) { toast.error('모든 필수 항목을 완료해주세요.', { description: '미완성된 질문이 있습니다.', icon: }); return; } try { console.log('설문조사 답변:', surveyAnswers); setSurveyData({ completed: true, answers: Object.values(surveyAnswers), timestamp: new Date().toISOString() }); toast.success("설문조사가 완료되었습니다!", { icon: }); } catch (error) { console.error('설문조사 저장 실패:', error); toast.error('설문조사 저장에 실패했습니다.'); } }; // 서명 저장 핸들러 const handleSave = async () => { const currentInstance = webViewerInstance.current || instance; if (!currentInstance) return; try { const { documentViewer, annotationManager } = currentInstance.Core; const doc = documentViewer.getDocument(); if (!doc) { toast.error("문서가 로드되지 않았습니다."); return; } const formData = await collectFormData(currentInstance); const xfdfString = await annotationManager.exportAnnotations(); const documentData = await doc.getFileData({ xfdfString, downloadType: "pdf", }); if (isComplianceTemplate && !surveyData.completed) { toast.error("준법 설문조사를 먼저 완료해주세요."); setActiveTab('survey'); return; } if (onSign) { await onSign(documentData, { formData, surveyData, signatureFields }); } else { toast.success("계약서가 성공적으로 서명되었습니다."); } handleClose(); } catch (error) { console.error("📛 서명 저장 실패:", error); toast.error("서명을 저장하는데 실패했습니다."); } }; // 다이얼로그 닫기 핸들러 const handleClose = () => { if (onClose) { onClose(); } else { setShowDialog(false); } }; // 동적 설문조사 컴포넌트 const SurveyComponent = () => { if (surveyLoading) { return (

설문조사를 불러오는 중...

); } if (!surveyTemplate) { return (

설문조사 템플릿을 불러올 수 없습니다.

); } const completedCount = surveyTemplate.questions.filter((q: any) => isSurveyQuestionComplete(q)).length; const progressPercentage = surveyTemplate.questions.length > 0 ? (completedCount / surveyTemplate.questions.length) * 100 : 0; const renderSurveyQuestion = (question: any) => { const answer = surveyAnswers[question.id]; const isComplete = isSurveyQuestionComplete(question); return (
{isComplete && ( )}
{question.questionType === 'RADIO' && ( updateSurveyAnswer(question.id, 'answerValue', value)} className="space-y-2" > {question.options?.map((option: any) => (
))}
)} {question.questionType === 'DROPDOWN' && (
{answer?.answerValue === 'OTHER' && ( updateSurveyAnswer(question.id, 'otherText', e.target.value)} className="mt-2" /> )}
)} {question.questionType === 'TEXTAREA' && (