"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; } // 초간단 안전한 서명 필드 감지 클래스 class AutoSignatureFieldDetector { private instance: WebViewerInstance; private mode: 'vendor' | 'buyer'; constructor(instance: WebViewerInstance, mode: 'vendor' | 'buyer' = 'vendor') { this.instance = instance; this.mode = mode; } 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 fieldName = await this.createSignatureFieldAtText(searchText); if (fieldName) { console.log(`✅ 텍스트 위치에 서명 필드 생성 완료: ${searchText}`); return [fieldName]; } 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 } = Core; const document = documentViewer.getDocument(); if (!document) return null; try { // 모든 페이지에서 텍스트 검색 const searchMode = Core.Search.Mode.PAGE_STOP | Core.Search.Mode.HIGHLIGHT; const searchOptions = { fullSearch: true, onResult: null, }; // 텍스트 검색 시작 const textSearchIterator = await document.getTextSearchIterator(); textSearchIterator.begin(searchText, searchMode); let searchResult = await textSearchIterator.next(); // 검색 결과가 있는 경우 if (searchResult && searchResult.resultCode === Core.Search.ResultCode.FOUND) { const pageNumber = searchResult.pageNum; const quads = searchResult.quads; if (quads && quads.length > 0) { // 첫 번째 검색 결과의 위치 가져오기 const quad = quads[0]; // 쿼드의 좌표를 기반으로 서명 필드 위치 계산 const x = Math.min(quad.x1, quad.x2, quad.x3, quad.x4); const y = Math.min(quad.y1, quad.y2, quad.y3, quad.y4); const textWidth = Math.abs(quad.x2 - quad.x1); const textHeight = Math.abs(quad.y3 - quad.y1); // 서명 필드 생성 const fieldName = `signature_at_text_${Date.now()}`; const flags = new Core.Annotations.WidgetFlags(); flags.set('Required', true); const field = new Core.Annotations.Forms.Field(fieldName, { type: 'Sig', flags }); const widget = new Core.Annotations.SignatureWidgetAnnotation(field, { Width: 150, Height: 50 }); widget.setPageNumber(pageNumber); // 텍스트 바로 아래 또는 오른쪽에 서명 필드 배치 // 옵션 1: 텍스트 바로 아래 widget.setX(x); widget.setY(y + textHeight + 5); // 텍스트 아래 5픽셀 간격 // 옵션 2: 텍스트 오른쪽 (필요시 아래 주석 해제) // widget.setX(x + textWidth + 10); // 텍스트 오른쪽 10픽셀 간격 // widget.setY(y); widget.setWidth(150); widget.setHeight(50); // 필드 매니저에 추가 const fm = annotationManager.getFieldManager(); fm.addField(field); annotationManager.addAnnotation(widget); annotationManager.drawAnnotationsFromList([widget]); console.log(`📌 서명 필드를 페이지 ${pageNumber}의 "${searchText}" 위치에 생성`); return fieldName; } } console.log(`⚠️ "${searchText}" 텍스트를 찾을 수 없음`); return null; } catch (error) { console.error(`📛 텍스트 검색 중 오류: ${error}`); return null; } } 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 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); // 구매자 모드일 때는 왼쪽 하단으로 위치 설정 if (this.mode === 'buyer') { widget.setX(w * 0.1); // 왼쪽 (10%) widget.setY(h * 0.85); // 하단 (85%) } else { // 협력업체 모드일 때는 기존처럼 오른쪽 widget.setX(w * 0.7); // 오른쪽 (70%) widget.setY(h * 0.85); // 하단 (85%) } 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 } = Core; const document = documentViewer.getDocument(); if (!document) return; try { console.log('🔍 구매자 서명란 자동 서명 시작...'); // "삼성중공업_서명란" 텍스트 검색 const searchText = '삼성중공업_서명란'; const textSearchIterator = await document.getTextSearchIterator(); textSearchIterator.begin(searchText, Core.Search.Mode.PAGE_STOP | Core.Search.Mode.HIGHLIGHT); let searchResult = await textSearchIterator.next(); if (searchResult && searchResult.resultCode === Core.Search.ResultCode.FOUND) { const pageNumber = searchResult.pageNum; const quads = searchResult.quads; if (quads && quads.length > 0) { const quad = quads[0]; const x = Math.min(quad.x1, quad.x2, quad.x3, quad.x4); const y = Math.min(quad.y1, quad.y2, quad.y3, quad.y4); const textHeight = Math.abs(quad.y3 - quad.y1); // 구매자 서명 이미지 가져오기 const buyerSignature = await getBuyerSignatureFileWithFallback(); if (buyerSignature) { // 스탬프 어노테이션 생성 const stamp = new Core.Annotations.StampAnnotation(); stamp.PageNumber = pageNumber; stamp.X = x; stamp.Y = y + textHeight + 5; // 텍스트 아래 5픽셀 stamp.Width = 150; stamp.Height = 50; await stamp.setImageData(buyerSignature.data.dataUrl); // 어노테이션 추가 annotationManager.addAnnotation(stamp); annotationManager.drawAnnotationsFromList([stamp]); console.log('✅ 구매자 서명 자동 적용 완료'); toast.info('삼성중공업 서명이 자동으로 적용되었습니다.', { duration: 3000 }); } } } } 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 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); // 처리 완료 표시 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 }; } // 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 [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 전달 const { signatureFields, isProcessing: isAutoSignProcessing, hasSignatureFields, error: autoSignError } = 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 && isComplianceTemplate && !surveyTemplate && mode !== 'buyer') { loadSurveyTemplate(); } if (isOpen) { setIsInitialLoaded(false); currentDocumentPath.current = ""; } }, [isOpen, isComplianceTemplate, 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 () => { setSurveyLoading(true); try { const template = await getActiveSurveyTemplate(); setSurveyTemplate(template); } catch (error) { console.error('📛 설문조사 템플릿 로드 실패:', 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; if (signatureImage) { await stamp.setImageData(signatureImage.data.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); if (newTab === "survey" || newTab === "clauses") return; const currentInstance = webViewerInstance.current || instance; if (!currentInstance || fileLoading) return; if (newTab === 'survey' && !surveyTemplate && !surveyLoading && mode !== 'buyer') { loadSurveyTemplate(); } 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; return (
{isAutoSignProcessing ? ( 서명 필드 생성 중... ) : autoSignError ? ( 자동 생성 실패 ) : hasSignatureFields ? ( {signatureFields.length}개 서명 필드 자동 생성됨 {mode === 'buyer' ? '(왼쪽 하단)' : ''} ) : 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 && (

문서 로딩 중...

)}
)}
); }