"use client"; import React, { useState, useEffect, useRef, SetStateAction, Dispatch, } from "react"; import { WebViewerInstance } from "@pdftron/webviewer"; import { Loader2, FileText, ClipboardList, AlertTriangle, FileSignature, Target, CheckCircle2, BookOpen } 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 { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter, } from "@/components/ui/dialog"; import { getActiveSurveyTemplate, getVendorSignatureFile, type SurveyTemplateWithQuestions } from '../service'; import { useConditionalSurvey } from '../vendor-table/survey-conditional'; import { SurveyComponent } from './SurveyComponent'; import { GtcClausesComponent } from './GtcClausesComponent'; import { getBuyerSignatureFileWithFallback } from "@/lib/shi-signature/buyer-signature"; interface FileInfo { path: string; name: string; type: 'main' | 'attachment' | 'survey' | 'clauses'; } 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>; onSurveyComplete?: () => void; onSignatureComplete?: () => void; onGtcCommentStatusChange?: ( hasComments: boolean, commentCount: number, reviewStatus?: string, isComplete?: boolean ) => void; mode?: 'vendor' | 'buyer'; // 추가된 mode prop t?: (key: string) => string; } // 자동 서명 필드 생성을 위한 타입 정의 interface SignaturePattern { regex: RegExp; name: string; priority: number; offsetX?: number; offsetY?: number; width?: number; height?: number; } // 서명 필드 위치 정보를 저장하는 인터페이스 interface SignatureFieldLocation { pageNumber: number; x: number; y: number; fieldName: string; searchText: string; } // 초간단 안전한 서명 필드 감지 클래스 class AutoSignatureFieldDetector { private instance: WebViewerInstance; private mode: 'vendor' | 'buyer'; private location: SignatureFieldLocation | null = null; // 위치 정보 저장 constructor(instance: WebViewerInstance, mode: 'vendor' | 'buyer' = 'vendor') { this.instance = instance; this.mode = mode; } // 위치 정보 getter 추가 getLocation(): SignatureFieldLocation | null { return this.location; } async detectAndCreateSignatureFields(): Promise { console.log(`🔍 텍스트 기반 서명 필드 감지 시작... (모드: ${this.mode})`); try { if (!this.instance?.Core?.documentViewer) { throw new Error("WebViewer 인스턴스가 유효하지 않습니다."); } const { Core } = this.instance; const { documentViewer } = Core; const document = documentViewer.getDocument(); if (!document) { throw new Error("PDF 문서가 로드되지 않았습니다."); } // 모드에 따라 검색할 텍스트 결정 const searchText = this.mode === 'buyer' ? '삼성중공업_서명란' : '협력업체_서명란'; console.log(`📄 "${searchText}" 텍스트 검색 중...`); // 텍스트 검색 및 서명 필드 생성 (모든 매치 찾기) const fieldNames = await this.createSignatureFieldAtText(searchText); if (fieldNames.length > 0) { console.log(`✅ ${fieldNames.length}개의 "${searchText}" 위치에 서명 필드 생성 완료`); return fieldNames; } else { // 텍스트를 찾지 못한 경우 기본 위치에 생성 console.log(`⚠️ "${searchText}" 텍스트를 찾지 못해 기본 위치에 생성`); const defaultField = await this.createSimpleSignatureField(); return [defaultField]; } } catch (error) { console.error("📛 서명 필드 생성 실패:", error); // 오류 발생 시 기본 위치에 생성 try { const defaultField = await this.createSimpleSignatureField(); return [defaultField]; } catch (fallbackError) { throw new Error("서명 필드 생성에 실패했습니다."); } } } private async createSignatureFieldAtText(searchText: string): Promise { const { Core } = this.instance; const { documentViewer, annotationManager, Annotations } = Core; const doc = documentViewer.getDocument(); if (!doc) return []; try { const pageCount = documentViewer.getPageCount(); const fieldNames: string[] = []; // 모든 페이지에서 모든 매치 찾기 for (let pageNumber = 1; pageNumber <= pageCount; pageNumber++) { // 1) 페이지 텍스트 로드 const pageText = await doc.loadPageText(pageNumber); if (!pageText) continue; // 2) 해당 페이지에서 모든 검색어 위치 찾기 let searchIndex = 0; while (true) { const startIndex = pageText.indexOf(searchText, searchIndex); if (startIndex === -1) break; // 더 이상 찾을 수 없으면 다음 페이지로 const endIndex = startIndex + searchText.length; // 3) 검색어의 문자 단위 Quads 얻기 const quads = await doc.getTextPosition(pageNumber, startIndex, endIndex); if (!quads || quads.length === 0) { searchIndex = startIndex + 1; continue; } // 첫 글자의 quad만 사용해 대략적인 위치 산출 const q = quads[0] as any; // PDFTron의 Quad 타입 const x = Math.min(q.x1, q.x2, q.x3, q.x4); const y = Math.min(q.y1, q.y2, q.y3, q.y4); const textHeight = Math.abs(q.y3 - q.y1); // 4) 서명 필드 생성 const fieldName = `signature_at_text_${Date.now()}_${fieldNames.length}`; const signatureY = y + textHeight + 5; // 첫 번째 필드의 위치 정보만 저장 (서명란으로 이동 기능용) if (fieldNames.length === 0) { this.location = { pageNumber, x, y: signatureY, fieldName, searchText }; } const flags = new Annotations.WidgetFlags(); flags.set('Required', true); const field = new Core.Annotations.Forms.Field(fieldName, { type: 'Sig', flags }); const widget = new Annotations.SignatureWidgetAnnotation(field, { Width: 150, Height: 50 }); widget.setPageNumber(pageNumber); // 텍스트 바로 아래에 배치 widget.setX(x); widget.setY(signatureY); widget.setWidth(150); widget.setHeight(50); const fm = annotationManager.getFieldManager(); fm.addField(field); annotationManager.addAnnotation(widget); annotationManager.drawAnnotationsFromList([widget]); fieldNames.push(fieldName); console.log(`✅ 페이지 ${pageNumber}의 "${searchText}" 위치에 서명 필드 생성 (${fieldNames.length}번째)`); // 다음 검색을 위해 인덱스 이동 searchIndex = startIndex + 1; } } return fieldNames; } catch (e) { console.error('텍스트 기반 서명 필드 생성 중 오류', e); return []; } } private async createSimpleSignatureField(): Promise { const { Core } = this.instance; const { documentViewer, annotationManager, Annotations } = Core; const page = documentViewer.getPageCount(); const w = documentViewer.getPageWidth(page) || 612; const h = documentViewer.getPageHeight(page) || 792; const fieldName = `simple_signature_${Date.now()}`; // 구매자 모드일 때는 왼쪽 하단으로 위치 설정 const x = this.mode === 'buyer' ? w * 0.1 : w * 0.7; const y = h * 0.85; // 위치 정보 저장 this.location = { pageNumber: page, x, y, fieldName, searchText: this.mode === 'buyer' ? '삼성중공업_서명란' : '협력업체_서명란' }; const flags = new Annotations.WidgetFlags(); flags.set('Required', true); const field = new Core.Annotations.Forms.Field(fieldName, { type: 'Sig', flags }); const widget = new Annotations.SignatureWidgetAnnotation(field, { Width: 150, Height: 50 }); widget.setPageNumber(page); widget.setX(x); widget.setY(y); widget.setWidth(150); widget.setHeight(50); const fm = annotationManager.getFieldManager(); fm.addField(field); annotationManager.addAnnotation(widget); annotationManager.drawAnnotationsFromList([widget]); return fieldName; } } const applyBuyerSignatureAutomatically = async (instance: WebViewerInstance) => { const { Core } = instance; const { documentViewer, annotationManager, Annotations } = Core; const doc = documentViewer.getDocument(); if (!doc) return; try { console.log('🔍 구매자 자동 서명: "삼성중공업_서명란" 전체 검색'); const TARGET = '삼성중공업_서명란'; // ✅ 정확히 이 문자열만 허용 const pageCount = documentViewer.getPageCount(); let signatureCount = 0; const buyerSignature = await getBuyerSignatureFileWithFallback(); // 기존 프로젝트 함수 그대로 사용 if (!buyerSignature?.data?.dataUrl) { console.warn('⚠️ 구매자 서명 이미지가 없습니다.'); return; } // 모든 페이지에서 모든 "삼성중공업_서명란" 찾기 for (let pageNumber = 1; pageNumber <= pageCount; pageNumber++) { const pageText = await doc.loadPageText(pageNumber); if (!pageText) continue; // 한 페이지에서 여러 개의 매치를 찾기 위해 반복 검색 let searchIndex = 0; while (true) { const startIndex = pageText.indexOf(TARGET, searchIndex); if (startIndex === -1) break; // 더 이상 찾을 수 없으면 다음 페이지로 const endIndex = startIndex + TARGET.length; // 문자 쿼드 → 바운딩 박스 const quads = await doc.getTextPosition(pageNumber, startIndex, endIndex); if (!quads?.length) { searchIndex = startIndex + 1; continue; } const xs: number[] = [], ys: number[] = []; quads.forEach((q: any) => { xs.push(q.x1, q.x2, q.x3, q.x4); ys.push(q.y1, q.y2, q.y3, q.y4); }); const minX = Math.min(...xs), maxY = Math.max(...ys); // 텍스트 바로 아래에 스탬프(서명 이미지) 배치 const widgetX = minX; const widgetY = maxY + 5; const widgetW = 150; const widgetH = 50; const stamp = new Annotations.StampAnnotation(); stamp.PageNumber = pageNumber; stamp.X = widgetX; stamp.Y = widgetY; stamp.Width = widgetW; stamp.Height = widgetH; await stamp.setImageData(buyerSignature.data.dataUrl); annotationManager.addAnnotation(stamp); annotationManager.drawAnnotationsFromList([stamp]); signatureCount++; console.log(`✅ 페이지 ${pageNumber}의 "삼성중공업_서명란" 위치에 자동 서명 적용 완료 (${signatureCount}번째)`); // 다음 검색을 위해 인덱스 이동 searchIndex = startIndex + 1; } } if (signatureCount > 0) { console.log(`✅ 총 ${signatureCount}개의 "삼성중공업_서명란" 위치에 자동 서명 적용 완료`); } else { console.warn('⚠️ 문서에서 "삼성중공업_서명란"을 찾지 못했습니다.'); } } catch (error) { console.error('구매자 자동 서명 처리 실패:', error); } }; function useAutoSignatureFields(instance: WebViewerInstance | null, mode: 'vendor' | 'buyer' = 'vendor') { const [signatureFields, setSignatureFields] = useState([]); const [isProcessing, setIsProcessing] = useState(false); const [error, setError] = useState(null); const [signatureLocation, setSignatureLocation] = useState(null); // 위치 정보 state 추가 // 한 번만 실행되도록 보장하는 플래그들 const processingRef = useRef(false); const timeoutRef = useRef(null); const processedDocumentRef = useRef(null); // 처리된 문서 추적 const handlerRef = useRef<(() => void) | null>(null); // 핸들러 참조 저장 useEffect(() => { if (!instance) return; const { documentViewer } = instance.Core; // 새로운 핸들러 생성 (참조가 변하지 않도록) const handleDocumentLoaded = () => { // 현재 문서의 고유 식별자 생성 const currentDoc = documentViewer.getDocument(); const documentId = currentDoc ? `${currentDoc.getFilename()}_${Date.now()}` : null; // 같은 문서를 이미 처리했다면 스킵 if (documentId && processedDocumentRef.current === documentId) { console.log("📛 이미 처리된 문서이므로 스킵:", documentId); return; } if (processingRef.current) { console.log("📛 이미 처리 중이므로 스킵"); return; } // 이전 타이머 클리어 if (timeoutRef.current) { clearTimeout(timeoutRef.current); timeoutRef.current = null; } timeoutRef.current = setTimeout(async () => { // 다시 한번 중복 체크 if (processingRef.current) return; if (documentId && processedDocumentRef.current === documentId) return; processingRef.current = true; setIsProcessing(true); setError(null); try { console.log("📄 문서 로드 완료, 서명 필드 처리 시작..."); if (!instance?.Core?.documentViewer?.getDocument()) { throw new Error("문서가 준비되지 않았습니다."); } // 기존 서명 필드 확인 const { annotationManager } = instance.Core; const existingAnnotations = annotationManager.getAnnotationsList(); const existingSignatureFields = existingAnnotations.filter( annot => annot instanceof instance.Core.Annotations.SignatureWidgetAnnotation ); // 이미 서명 필드가 있으면 생성하지 않음 if (existingSignatureFields.length > 0) { console.log("📋 기존 서명 필드 발견:", existingSignatureFields.length); const fieldNames = existingSignatureFields.map((field, idx) => field.getField()?.name || `existing_signature_${idx}` ); setSignatureFields(fieldNames); // 처리 완료 표시 if (documentId) { processedDocumentRef.current = documentId; } toast.success(`📋 ${fieldNames.length}개의 기존 서명 필드를 확인했습니다.`); return; } const detector = new AutoSignatureFieldDetector(instance, mode); // mode 전달 const fields = await detector.detectAndCreateSignatureFields(); setSignatureFields(fields); // 위치 정보 저장 const location = detector.getLocation(); if (location) { setSignatureLocation(location); } // 처리 완료 표시 if (documentId) { processedDocumentRef.current = documentId; } if (fields.length > 0) { const hasTextBasedField = fields.some(field => field.startsWith('signature_at_text_')); const hasSimpleField = fields.some(field => field.startsWith('simple_signature_')); if (hasTextBasedField) { const searchText = mode === 'buyer' ? '삼성중공업_서명란' : '협력업체_서명란'; toast.success(`📝 "${searchText}" 위치에 서명 필드가 생성되었습니다.`, { description: "해당 텍스트 근처의 파란색 영역에서 서명해주세요.", icon: , duration: 5000 }); } else if (hasSimpleField) { const positionMessage = mode === 'buyer' ? "마지막 페이지 왼쪽 하단의 파란색 영역에서 서명해주세요." : "마지막 페이지 하단의 파란색 영역에서 서명해주세요."; toast.info("📝 기본 위치에 서명 필드가 생성되었습니다.", { description: `검색 텍스트를 찾을 수 없어 ${positionMessage}`, icon: , duration: 5000 }); } } } catch (error) { console.error("📛 서명 필드 처리 실패:", error); const errorMessage = error instanceof Error ? error.message : "서명 필드 처리에 실패했습니다."; setError(errorMessage); toast.info("수동 서명 모드", { description: "문서에서 서명할 위치를 직접 클릭해주세요.", icon: }); } finally { setIsProcessing(false); processingRef.current = false; } }, 3000); }; // 핸들러 참조 저장 handlerRef.current = handleDocumentLoaded; // 이전 리스너 제거 (저장된 참조 사용) if (handlerRef.current) { documentViewer.removeEventListener('documentLoaded', handlerRef.current); } // 새 리스너 등록 documentViewer.addEventListener('documentLoaded', handleDocumentLoaded); // 이미 문서가 로드되어 있다면 즉시 실행 if (documentViewer.getDocument()) { // 짧은 지연 후 실행 (WebViewer 초기화 완료 보장) setTimeout(() => { if (!processingRef.current) { handleDocumentLoaded(); } }, 1000); } return () => { // 클리어 시 저장된 참조 사용 if (handlerRef.current) { documentViewer.removeEventListener('documentLoaded', handlerRef.current); } if (timeoutRef.current) { clearTimeout(timeoutRef.current); timeoutRef.current = null; } // 상태 리셋 processingRef.current = false; processedDocumentRef.current = null; handlerRef.current = null; }; }, [instance, mode]); // mode 의존성 추가 // 컴포넌트 언마운트 시 정리 useEffect(() => { return () => { if (timeoutRef.current) { clearTimeout(timeoutRef.current); } processingRef.current = false; processedDocumentRef.current = null; }; }, []); return { signatureFields, isProcessing, hasSignatureFields: signatureFields.length > 0, error, signatureLocation // 위치 정보 반환 }; } // 서명란으로 이동하는 함수 추가 const navigateToSignatureField = ( instance: WebViewerInstance | null, location: SignatureFieldLocation | null ) => { if (!instance || !location) { toast.error("서명란 위치를 찾을 수 없습니다."); return; } try { const { documentViewer, annotationManager } = instance.Core; // 1. 해당 페이지로 이동 documentViewer.setCurrentPage(location.pageNumber); // 2. 서명 필드 위젯 찾기 const annotations = annotationManager.getAnnotationsList(); const signatureWidget = annotations.find( (annot: any) => annot instanceof instance.Core.Annotations.SignatureWidgetAnnotation && annot.getField()?.name === location.fieldName ); if (signatureWidget) { // 위젯이 있으면 선택하여 자동으로 해당 위치로 스크롤 annotationManager.selectAnnotation(signatureWidget); // 추가로 스크롤 위치 조정 setTimeout(() => { try { // annotation을 다시 선택하여 스크롤 보장 annotationManager.selectAnnotation(signatureWidget); } catch (scrollError) { console.warn("스크롤 조정 실패:", scrollError); } }, 300); toast.success(`서명란으로 이동했습니다. (페이지 ${location.pageNumber})`); } else { // 위젯을 찾지 못한 경우 페이지만 이동 toast.info(`페이지 ${location.pageNumber}로 이동했습니다.`); } } catch (error) { console.error("서명란으로 이동 실패:", error); toast.error("서명란으로 이동하는데 실패했습니다."); } }; // XFDF 기반 서명 감지 function useSignatureDetection(instance: WebViewerInstance | null, onSignatureComplete?: () => void) { const [hasValidSignature, setHasValidSignature] = useState(false); const onCompleteRef = useRef(onSignatureComplete); useEffect(() => { onCompleteRef.current = onSignatureComplete }, [onSignatureComplete]); useEffect(() => { if (!instance?.Core) return; const { annotationManager, documentViewer } = instance.Core; const checkSignedByAppearance = () => { try { const annotations = annotationManager.getAnnotationsList(); const signatureWidgets = annotations.filter( annot => annot instanceof instance.Core.Annotations.SignatureWidgetAnnotation ); if (signatureWidgets.length === 0) { return false; } for (const widget of signatureWidgets) { const isSignedByAppearance = widget.isSignedByAppearance(); if (isSignedByAppearance) { return true; } } return false; } catch (error) { console.error('서명 위젯 확인 중 오류:', error); return false; } }; const checkSigned = async () => { try { const hasSignature = await checkSignedByAppearance(); if (hasSignature !== hasValidSignature) { setHasValidSignature(hasSignature); if (hasSignature && onCompleteRef.current) { onCompleteRef.current(); } } } catch (error) { console.error('서명 확인 중 오류:', error); } }; const onAnnotationChanged = (annotations: any[], action: string) => { console.log("서명 변경") // if (action === 'delete') return; setTimeout(checkSigned, 800); }; const onDocumentLoaded = () => { setTimeout(checkSigned, 2000); }; const onPageUpdated = () => { setTimeout(checkSigned, 1000); }; const onAnnotationSelected = () => { setTimeout(checkSigned, 500); }; const onAnnotationUnselected = () => { setTimeout(checkSigned, 1000); }; try { annotationManager.addEventListener('annotationChanged', onAnnotationChanged); annotationManager.addEventListener('annotationSelected', onAnnotationSelected); annotationManager.addEventListener('annotationUnselected', onAnnotationUnselected); documentViewer.addEventListener('documentLoaded', onDocumentLoaded); documentViewer.addEventListener('pageNumberUpdated', onPageUpdated); } catch (error) { console.error('이벤트 리스너 등록 실패:', error); } if (documentViewer.getDocument()) { setTimeout(checkSigned, 1000); } const pollInterval = setInterval(() => { checkSigned(); }, 5000); return () => { try { annotationManager.removeEventListener('annotationChanged', onAnnotationChanged); annotationManager.removeEventListener('annotationSelected', onAnnotationSelected); annotationManager.removeEventListener('annotationUnselected', onAnnotationUnselected); documentViewer.removeEventListener('documentLoaded', onDocumentLoaded); documentViewer.removeEventListener('pageNumberUpdated', onPageUpdated); clearInterval(pollInterval); } catch (error) { console.error('클린업 중 오류:', error); } }; }, [instance, hasValidSignature]); return { hasValidSignature }; } export function BasicContractSignViewer({ contractId, filePath, additionalFiles = [], templateName = "", isOpen = false, onClose, onSign, instance, setInstance, onSurveyComplete, onSignatureComplete, onGtcCommentStatusChange, mode = 'vendor', // 기본값 vendor t = (key: string) => key, }: BasicContractSignViewerProps) { const [fileLoading, setFileLoading] = useState(true); const [activeTab, setActiveTab] = useState("main"); const [surveyData, setSurveyData] = useState({}); const [isInitialLoaded, setIsInitialLoaded] = useState(false); const [surveyTemplate, setSurveyTemplate] = useState(null); const [surveyLoading, setSurveyLoading] = useState(false); const [surveyLoadAttempted, setSurveyLoadAttempted] = useState(false); const [gtcCommentStatus, setGtcCommentStatus] = useState<{ hasComments: boolean; commentCount: number; reviewStatus?: string; isComplete?: boolean; }>({ hasComments: false, commentCount: 0, isComplete: false }); console.log(filePath, "filePath") console.log(surveyTemplate, "surveyTemplate") const conditionalHandler = useConditionalSurvey(surveyTemplate); const viewer = useRef(null); const initialized = useRef(false); const isCancelled = useRef(false); const currentDocumentPath = useRef(""); const [showDialog, setShowDialog] = useState(isOpen); const webViewerInstance = useRef(null); // mode 전달 - signatureLocation도 받아오기 const { signatureFields, isProcessing: isAutoSignProcessing, hasSignatureFields, error: autoSignError, signatureLocation // 위치 정보 추가 } = useAutoSignatureFields(webViewerInstance.current || instance, mode); const { hasValidSignature } = useSignatureDetection(webViewerInstance.current || instance, onSignatureComplete); // 구매자 모드일 때는 템플릿 관련 로직 비활성화 const isComplianceTemplate = mode === 'buyer' ? false : templateName.includes('준법'); const isNDATemplate = mode === 'buyer' ? false : (templateName.includes('비밀유지') || templateName.includes('NDA')); const isGTCTemplate = mode === 'buyer' ? false : templateName.includes('GTC'); const allFiles: FileInfo[] = React.useMemo(() => { const files: FileInfo[] = []; if (filePath) { files.push({ path: filePath, name: templateName || "기본 계약서", type: "main", }); } // 구매자 모드일 때는 추가 파일, 설문조사, 조항 검토 탭 제외 if (mode === 'buyer') { return files; // 메인 계약서 파일만 반환 } 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", }); } if (isGTCTemplate) { files.push({ path: "", name: "조항 검토", type: "clauses", }); } return files; }, [filePath, additionalFiles, templateName, isComplianceTemplate, isGTCTemplate, mode]); const cleanupHtmlStyle = () => { const elements = document.querySelectorAll('.Document_container'); elements.forEach((elem) => { elem.remove(); }); }; 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) { setIsInitialLoaded(false); currentDocumentPath.current = ""; } // 구매자 모드가 아닐 때만 설문조사 템플릿 로드 if (isOpen && isComplianceTemplate && !surveyTemplate && !surveyLoading && mode !== 'buyer') { loadSurveyTemplate(); } }, [isOpen, isComplianceTemplate, surveyTemplate, surveyLoading, mode]); useEffect(() => { if (!filePath) return; 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}`; loadDocument(currentInstance, apiFilePath, true).then(() => { setIsInitialLoaded(true); }).catch((error) => { console.error("📛 문서 로드 실패:", error); }); } }, [filePath, instance]); const loadSurveyTemplate = async () => { // 이미 로딩 중이거나 템플릿이 있으면 중복 호출 방지 if (surveyLoading || surveyTemplate) { return; } setSurveyLoading(true); setSurveyLoadAttempted(true); // 로드 시도 표시 try { // 계약서 템플릿 이름에서 언어 판단 let language = 'ko'; // 기본값 한글 if (templateName && (templateName.includes('영문') || templateName.toLowerCase().includes('english'))) { language = 'en'; } const template = await getActiveSurveyTemplate(language); setSurveyTemplate(template); } catch (error) { setSurveyTemplate(null); } finally { setSurveyLoading(false); } }; useEffect(() => { if (!initialized.current && viewer.current) { initialized.current = true; isCancelled.current = false; const initializeWebViewer = () => { if (!viewer.current || isCancelled.current) { return; } const viewerElement = viewer.current; if (!viewerElement.isConnected) { setTimeout(initializeWebViewer, 100); return; } cleanupWebViewer(); import("@pdftron/webviewer").then(({ default: WebViewer }) => { if (isCancelled.current || !viewer.current) { return; } WebViewer( { path: "/pdftronWeb", licenseKey: process.env.NEXT_PUBLIC_PDFTRON_WEBVIEW_KEY, fullAPI: true, }, viewerElement ).then((newInstance) => { if (isCancelled.current) { return; } webViewerInstance.current = newInstance; setInstance(newInstance); setFileLoading(false); const { documentViewer, annotationManager, Annotations } = newInstance.Core; const { WidgetFlags } = Annotations; const FitMode = newInstance.UI.FitMode; const handleDocumentLoaded = async () => { setFileLoading(false); newInstance.UI.setFitMode(FitMode.FitWidth); // GTC 템플릿이 아닌 경우에만 구매자 자동 서명 적용 if (!templateName.includes('GTC')) { try { // 구매자 서명란 찾아서 자동 서명 await applyBuyerSignatureAutomatically(newInstance); } catch (error) { console.error('구매자 자동 서명 실패:', error); } } 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); // 구매자 모드가 아닐 때만 자동 서명 적용 if (mode !== 'buyer') { annotationManager.addEventListener('annotationChanged', async (annotList, type) => { for (const annot of annotList) { const { fieldName, X, Y, Width, Height, PageNumber } = annot; if (type === "add" && annot.Subject === "Widget") { const signatureImage = await getVendorSignatureFile() const stamp = new Annotations.StampAnnotation(); stamp.PageNumber = PageNumber; stamp.X = X; stamp.Y = Y; stamp.Width = Width; stamp.Height = Height; const dataUrl = signatureImage?.data?.dataUrl; if (dataUrl) { await stamp.setImageData(dataUrl); annot.sign(stamp); annot.setFieldFlag(WidgetFlags.READ_ONLY, true); } } } }); } newInstance.UI.setMinZoomLevel('25%'); newInstance.UI.setMaxZoomLevel('400%'); newInstance.UI.disableElements([ "toolbarGroup-Annotate", "toolbarGroup-Shapes", "toolbarGroup-Insert", "toolbarGroup-Edit", "toolbarGroup-FillAndSign", "toolbarGroup-Forms", "saveAsButton", "downloadButton", ]); documentViewer.addEventListener('documentLoadingError', (error) => { console.error("📛 문서 로딩 에러:", error); let showToast = true; let errorMessage = "문서를 불러오는데 실패했습니다."; if (error && typeof error === 'object') { const errorStr = JSON.stringify(error).toLowerCase(); if (errorStr.includes('linearized') || errorStr.includes('getreference')) { 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, mode]); 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) { return; } setFileLoading(true); try { 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; 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("📛 문서 로딩 실패:", 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 { annotationManager } = instance.Core; const fieldManager = annotationManager.getFieldManager(); const fields = fieldManager.getFields(); const formData: any = {}; fields.forEach((field: any) => { formData[field.name] = field.value; }); return formData; } catch (error) { console.error('📛 폼 데이터 수집 실패:', error); return {}; } }; const handleTabChange = async (newTab: string) => { setActiveTab(newTab); // survey 탭으로 변경 시 템플릿 로드 확인 if (newTab === 'survey' && !surveyTemplate && !surveyLoading && mode !== 'buyer') { loadSurveyTemplate(); } if (newTab === "survey" || newTab === "clauses") 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' && f.type !== 'clauses')[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}`; 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(() => { 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}`; currentDocumentPath.current = ""; loadDocument(currentInstance, apiFilePath, true).then(() => { setIsInitialLoaded(true); }).catch((error) => { console.error("📛 초기 마운트 로드 실패:", error); }); }, [webViewerInstance.current, instance, filePath, activeTab, isInitialLoaded, allFiles.length]); 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", }); // 구매자 모드일 때는 설문조사와 GTC 검증 건너뛰기 if (mode !== 'buyer') { if (isComplianceTemplate && !surveyData.completed) { toast.error("준법 설문조사를 먼저 완료해주세요."); setActiveTab('survey'); return; } if (isGTCTemplate && gtcCommentStatus.hasComments && !gtcCommentStatus.isComplete) { toast.error("GTC 조항에 미해결 코멘트가 있어 서명할 수 없습니다."); toast.info("모든 코멘트를 삭제하거나 협의를 완료한 후 서명해주세요."); setActiveTab('clauses'); return; } } if (onSign) { await onSign(documentData, { formData, surveyData, signatureFields }); } else { const actionText = mode === 'buyer' ? "최종승인" : "서명"; toast.success(`계약서가 성공적으로 ${actionText}되었습니다.`); } handleClose(); } catch (error) { console.error(`📛 ${mode === 'buyer' ? '최종승인' : '서명'} 저장 실패:`, error); const actionText = mode === 'buyer' ? "최종승인" : "서명"; toast.error(`${actionText}을 저장하는데 실패했습니다.`); } }; const handleClose = () => { if (onClose) { onClose(); } else { setShowDialog(false); } }; // 서명 상태 표시 컴포넌트 const SignatureFieldsStatus = () => { if (!hasSignatureFields && !isAutoSignProcessing && !autoSignError && !hasValidSignature) return null; const currentInstance = webViewerInstance.current || instance; return (
{isAutoSignProcessing ? ( 서명 필드 생성 중... ) : autoSignError ? ( 자동 생성 실패 ) : hasSignatureFields ? ( <> {signatureFields.length}개 서명 필드 자동 생성됨 {mode === 'buyer' ? '(왼쪽 하단)' : ''} {signatureLocation && ( )} ) : null} {hasValidSignature && ( 서명 완료됨 )}
); }; // 인라인 뷰어 렌더링 if (!isOpen && !onClose) { return (
{/* 구매자 모드에서는 탭 없이 단일 뷰어만 표시 */} {allFiles.length > 1 && mode !== 'buyer' ? (
{allFiles.map((file, index) => { let tabId: string; if (index === 0) { tabId = 'main'; } else if (file.type === 'survey') { tabId = 'survey'; } else if (file.type === 'clauses') { tabId = 'clauses'; } else { const fileOnlyIndex = allFiles.slice(0, index).filter(f => f.type !== 'survey' && f.type !== 'clauses').length; tabId = `file-${fileOnlyIndex}`; } return (
{file.type === 'survey' ? ( ) : file.type === 'clauses' ? ( ) : ( )} {file.name} {file.type === 'survey' && surveyData.completed && ( 완료 )} {file.type === 'clauses' && gtcCommentStatus.hasComments && ( 코멘트 {gtcCommentStatus.commentCount} )}
); })}
{/* 분리된 SurveyComponent 사용 */}
{/* GTC 조항 컴포넌트 */} { setGtcCommentStatus({ hasComments, commentCount, reviewStatus, isComplete }); onGtcCommentStatusChange?.(hasComments, commentCount, reviewStatus, isComplete); }} t={t} />
{fileLoading && (

문서 로딩 중...

)}
) : (
{fileLoading && (

문서 로딩 중...

)}
)}
); } const handleGtcCommentStatusChange = React.useCallback(( hasComments: boolean, commentCount: number, reviewStatus?: string, isComplete?: boolean ) => { setGtcCommentStatus({ hasComments, commentCount, reviewStatus, isComplete }); onGtcCommentStatusChange?.(hasComments, commentCount, reviewStatus, isComplete); }, [onGtcCommentStatusChange]); // 다이얼로그 뷰어 렌더링 return ( {mode === 'buyer' ? '구매자 최종승인' : '기본계약서 서명'} 계약서를 확인하고 {mode === 'buyer' ? '최종승인을' : '서명을'} 진행해주세요. {mode !== 'buyer' && isComplianceTemplate && ( 📋 준법 설문조사를 먼저 완료해주세요. )} {mode !== 'buyer' && isGTCTemplate && ( 📋 GTC 조항을 검토하고 코멘트가 없는지 확인해주세요. )} {hasSignatureFields && ( 🎯 서명 위치가 자동으로 감지되었습니다{mode === 'buyer' ? ' (왼쪽 하단)' : ''}. )} {hasValidSignature && ( ✅ {mode === 'buyer' ? '승인이' : '서명이'} 완료되었습니다. )} {mode !== 'buyer' && isGTCTemplate && gtcCommentStatus.hasComments && ( ⚠️ GTC 조항에 {gtcCommentStatus.commentCount}개의 코멘트가 있어 서명할 수 없습니다. )} {mode !== 'buyer' && isGTCTemplate && gtcCommentStatus.hasComments && !gtcCommentStatus.isComplete && ( ⚠️ GTC 조항에 {gtcCommentStatus.commentCount}개의 미해결 코멘트가 있어 서명할 수 없습니다. )} {mode !== 'buyer' && isGTCTemplate && gtcCommentStatus.hasComments && gtcCommentStatus.isComplete && ( ✅ GTC 조항 협의가 완료되어 서명 가능합니다. )}
{/* 구매자 모드에서는 탭 없이 단일 뷰어만 표시 */} {allFiles.length > 1 && mode !== 'buyer' ? (
{allFiles.map((file, index) => { let tabId: string; if (index === 0) { tabId = 'main'; } else if (file.type === 'survey') { tabId = 'survey'; } else if (file.type === 'clauses') { tabId = 'clauses'; } else { const fileOnlyIndex = allFiles.slice(0, index).filter(f => f.type !== 'survey' && f.type !== 'clauses').length; tabId = `file-${fileOnlyIndex}`; } return (
{file.type === 'survey' ? ( ) : file.type === 'clauses' ? ( ) : ( )} {file.name} {file.type === 'survey' && surveyData.completed && ( 완료 )} {file.type === 'clauses' && gtcCommentStatus.hasComments && ( 코멘트 {gtcCommentStatus.commentCount} )}
); })}
{/* 분리된 SurveyComponent 사용 */}
{/* GTC 조항 컴포넌트 */}
{fileLoading && (

문서 로딩 중...

)}
) : (
{fileLoading && (

문서 로딩 중...

)}
)}
); }