diff options
| -rw-r--r-- | lib/basic-contract/viewer/AutoSignatureFieldDetector.tsx | 493 | ||||
| -rw-r--r-- | lib/basic-contract/viewer/basic-contract-sign-viewer.tsx | 391 |
2 files changed, 265 insertions, 619 deletions
diff --git a/lib/basic-contract/viewer/AutoSignatureFieldDetector.tsx b/lib/basic-contract/viewer/AutoSignatureFieldDetector.tsx deleted file mode 100644 index 7de8062c..00000000 --- a/lib/basic-contract/viewer/AutoSignatureFieldDetector.tsx +++ /dev/null @@ -1,493 +0,0 @@ -// PDF 텍스트 패턴 기반 자동 서명 필드 생성 시스템 - -interface SignaturePattern { - regex: RegExp; - name: string; - priority: number; - offsetX?: number; // 텍스트로부터 X축 오프셋 - offsetY?: number; // 텍스트로부터 Y축 오프셋 - 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, // "서명:" 텍스트 오른쪽으로 80px - offsetY: -5, // 약간 위로 - width: 150, - height: 40 - }, - { - regex: /서명란\s*[_\-\s]{0,}/gi, - name: "한국어_서명란", - priority: 9, - offsetX: 60, - offsetY: -5, - width: 150, - height: 40 - }, - { - regex: /서명\s*[_\-\s]{5,}/gi, - name: "한국어_서명_라인", - priority: 8, - offsetX: 50, - offsetY: -5, - width: 150, - height: 40 - }, - { - regex: /(계약자|갑|을)\s*서명\s*[::]?\s*[_\-\s]{0,}/gi, - name: "한국어_계약자_서명", - priority: 9, - offsetX: 100, - 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 - }, - { - regex: /sign\s*[::]\s*[_\-\s]{3,}/gi, - name: "영어_sign_콜론", - priority: 7, - offsetX: 60, - offsetY: -5, - width: 150, - height: 40 - }, - - // 날짜와 함께 나오는 패턴들 - { - regex: /날짜\s*[::]\s*[_\-\s]{3,}.*?서명\s*[::]\s*[_\-\s]{3,}/gi, - name: "날짜_서명_조합", - priority: 10, - offsetX: 0, - offsetY: -5, - width: 150, - height: 40 - }, - { - regex: /date\s*[::]\s*[_\-\s]{3,}.*?signature\s*[::]\s*[_\-\s]{3,}/gi, - name: "date_signature_조합", - priority: 10, - offsetX: 0, - offsetY: -5, - width: 150, - height: 40 - }, - - // 일반적인 양식 패턴들 - { - regex: /이름\s*[::]\s*[_\-\s]{5,}.*?서명\s*[::]\s*[_\-\s]{5,}/gi, - name: "이름_서명_조합", - priority: 8, - offsetX: 0, - offsetY: -5, - width: 150, - height: 40 - }, - { - regex: /name\s*[::]\s*[_\-\s]{5,}.*?signature\s*[::]\s*[_\-\s]{5,}/gi, - name: "name_signature_조합", - priority: 8, - offsetX: 0, - offsetY: -5, - width: 150, - height: 40 - } - ]; - } - - // 📄 메인 함수: 문서에서 서명 패턴 감지 및 필드 생성 - async detectAndCreateSignatureFields(): Promise<string[]> { - console.log("🔍 자동 서명 필드 감지 시작..."); - - try { - const { Core } = this.instance; - const { documentViewer } = Core; - - await Core.PDFNet.initialize(); - const doc = await documentViewer.getDocument().getPDFDoc(); - - // 1. 기존 서명 필드 확인 - const existingFields = await this.getExistingSignatureFields(doc); - console.log(`📊 기존 서명 필드: ${existingFields.length}개`); - - if (existingFields.length > 0) { - console.log("✅ 기존 서명 필드가 있으므로 자동 생성 스킵"); - return existingFields.map(f => f.name); - } - - // 2. 텍스트 패턴 기반 서명 위치 감지 - const detectedLocations = await this.detectSignatureLocations(doc); - console.log(`🎯 감지된 서명 위치: ${detectedLocations.length}개`); - - // 3. 감지된 위치에 서명 필드 생성 - const createdFields: string[] = []; - for (const location of detectedLocations) { - try { - const fieldName = await this.createSignatureFieldAtLocation(doc, location); - createdFields.push(fieldName); - console.log(`✅ 서명 필드 생성: ${fieldName}`); - } catch (error) { - console.error(`📛 서명 필드 생성 실패:`, error); - } - } - - // 4. 문서 업데이트 - if (createdFields.length > 0) { - await documentViewer.refreshAll(); - await documentViewer.updateView(); - console.log(`🎉 총 ${createdFields.length}개 서명 필드 자동 생성 완료`); - } else { - console.warn("⚠️ 서명 패턴을 찾지 못했습니다. 기본 서명 필드 생성..."); - const defaultField = await this.createDefaultSignatureField(doc); - createdFields.push(defaultField); - } - - return createdFields; - - } catch (error) { - console.error("📛 자동 서명 필드 생성 실패:", error); - return []; - } - } - - // 기존 서명 필드 확인 - private async getExistingSignatureFields(doc: any): Promise<any[]> { - const { Core } = this.instance; - const fields = []; - - try { - const pageCount = await doc.getPageCount(); - - for (let i = 1; i <= pageCount; i++) { - const page = await doc.getPage(i); - const numAnnots = await page.getNumAnnots(); - - for (let j = 0; j < numAnnots; j++) { - const annot = await page.getAnnot(j); - const annotType = await annot.getType(); - - if (annotType === Core.PDFNet.Annot.Type.e_Widget) { - const widget = await Core.PDFNet.WidgetAnnot.cast(annot); - const field = await widget.getField(); - const fieldType = await field.getType(); - - if (fieldType === Core.PDFNet.Field.Type.e_signature) { - const fieldName = await field.getName(); - fields.push({ name: fieldName, widget, page: i }); - } - } - } - } - } catch (error) { - console.warn("기존 필드 확인 중 에러:", error); - } - - return fields; - } - - // 텍스트 패턴 기반 서명 위치 감지 - private async detectSignatureLocations(doc: any): Promise<DetectedSignatureLocation[]> { - const { Core } = this.instance; - const detectedLocations: DetectedSignatureLocation[] = []; - - try { - const pageCount = await doc.getPageCount(); - - for (let pageNum = 1; pageNum <= pageCount; pageNum++) { - const page = await doc.getPage(pageNum); - - // 텍스트 추출 - const textExtractor = await Core.PDFNet.TextExtractor.create(); - await textExtractor.begin(page); - - // 텍스트와 위치 정보 추출 - const wordList = []; - let line = await textExtractor.getFirstLine(); - - while (line) { - let word = await line.getFirstWord(); - while (word) { - const wordText = await word.getString(); - const wordBox = await word.getBBox(); - - wordList.push({ - text: wordText, - x1: await wordBox.getX1(), - y1: await wordBox.getY1(), - x2: await wordBox.getX2(), - y2: await wordBox.getY2() - }); - - word = await word.getNext(); - } - line = await line.getNext(); - } - - // 전체 페이지 텍스트 조합 - const fullText = wordList.map(w => w.text).join(' '); - - // 패턴 매칭 - for (const pattern of this.signaturePatterns) { - const matches = Array.from(fullText.matchAll(pattern.regex)); - - for (const match of matches) { - // 매치된 텍스트의 위치 찾기 - const matchText = match[0]; - const matchStart = match.index || 0; - - // 대략적인 위치 계산 (개선 가능) - const location = this.calculateTextLocation(wordList, matchStart, matchText.length); - - if (location) { - detectedLocations.push({ - pageIndex: pageNum - 1, - text: matchText, - rect: { - x1: location.x1 + (pattern.offsetX || 0), - y1: location.y1 + (pattern.offsetY || 0), - x2: location.x1 + (pattern.offsetX || 0) + (pattern.width || 150), - y2: location.y1 + (pattern.offsetY || 0) + (pattern.height || 40) - }, - pattern: pattern, - confidence: pattern.priority - }); - - console.log(`🎯 패턴 매치: "${matchText}" (${pattern.name}) 페이지 ${pageNum}`); - } - } - } - } - - // 신뢰도 순으로 정렬 (중복 제거 포함) - return this.deduplicateAndSort(detectedLocations); - - } catch (error) { - console.error("텍스트 패턴 감지 실패:", error); - return []; - } - } - - // 텍스트 위치 계산 (개선된 버전) - private calculateTextLocation(wordList: any[], startIndex: number, length: number): any { - if (wordList.length === 0) return null; - - // 간단한 구현: 첫 번째 단어의 위치 사용 - // 실제로는 더 정교한 텍스트 매칭 필요 - const totalChars = wordList.map(w => w.text).join(' ').length; - const ratio = startIndex / totalChars; - const targetWordIndex = Math.floor(ratio * wordList.length); - - const targetWord = wordList[Math.min(targetWordIndex, wordList.length - 1)]; - return targetWord; - } - - // 중복 제거 및 정렬 - private deduplicateAndSort(locations: DetectedSignatureLocation[]): DetectedSignatureLocation[] { - // 같은 페이지의 너무 가까운 위치들 제거 - const filtered = locations.filter((loc, index) => { - return !locations.slice(0, index).some(prevLoc => - prevLoc.pageIndex === loc.pageIndex && - Math.abs(prevLoc.rect.x1 - loc.rect.x1) < 100 && - Math.abs(prevLoc.rect.y1 - loc.rect.y1) < 50 - ); - }); - - // 신뢰도(우선순위) 순으로 정렬 - return filtered.sort((a, b) => b.confidence - a.confidence); - } - - // 감지된 위치에 서명 필드 생성 - private async createSignatureFieldAtLocation(doc: any, location: DetectedSignatureLocation): Promise<string> { - const { Core } = this.instance; - - const fieldName = `auto_signature_${location.pageIndex + 1}_${Date.now()}`; - const page = await doc.getPage(location.pageIndex + 1); - - // 디지털 서명 필드 생성 - const sigField = await doc.createDigitalSignatureField(fieldName); - - // 서명 위젯 생성 - const rect = await Core.PDFNet.Rect.init( - location.rect.x1, - location.rect.y1, - location.rect.x2, - location.rect.y2 - ); - - const widget = await Core.PDFNet.SignatureWidget.createWithDigitalSignatureField( - doc, rect, sigField - ); - - // 위젯 스타일 설정 - await widget.setBackgroundColor( - await Core.PDFNet.ColorPt.init(0.95, 0.95, 1.0), // 연한 파란색 - 3 // RGB - ); - - await widget.setBorderColor( - await Core.PDFNet.ColorPt.init(0.2, 0.4, 0.8), // 파란색 테두리 - 3 // RGB - ); - - // 페이지에 위젯 추가 - await page.annotPushBack(widget); - - console.log(`✅ 자동 서명 필드 생성: ${fieldName} (패턴: ${location.pattern.name})`); - return fieldName; - } - - // 기본 서명 필드 생성 (패턴을 찾지 못한 경우) - private async createDefaultSignatureField(doc: any): Promise<string> { - const { Core } = this.instance; - - console.log("⚠️ 서명 패턴 미발견, 기본 위치에 서명 필드 생성"); - - const pageCount = await doc.getPageCount(); - const lastPage = await doc.getPage(pageCount); - const pageInfo = await lastPage.getPageInfo(); - const pageWidth = await pageInfo.getWidth(); - const pageHeight = await pageInfo.getHeight(); - - const fieldName = `default_signature_${Date.now()}`; - const sigField = await doc.createDigitalSignatureField(fieldName); - - // 마지막 페이지 하단 중앙에 배치 - const rect = await Core.PDFNet.Rect.init( - pageWidth * 0.3, // 페이지 너비 30% 지점 - pageHeight * 0.1, // 페이지 하단 10% 지점 - pageWidth * 0.7, // 페이지 너비 70% 지점 - pageHeight * 0.2 // 페이지 하단 20% 지점 - ); - - const widget = await Core.PDFNet.SignatureWidget.createWithDigitalSignatureField( - doc, rect, sigField - ); - - await widget.setBackgroundColor( - await Core.PDFNet.ColorPt.init(1.0, 0.95, 0.95), // 연한 핑크색 (주의 표시) - 3 - ); - - await widget.setBorderColor( - await Core.PDFNet.ColorPt.init(0.8, 0.2, 0.2), // 빨간색 테두리 - 3 - ); - - await lastPage.annotPushBack(widget); - - return fieldName; - } - } - - // ✅ BasicContractSignViewer에 통합할 수 있는 함수 - export async function addAutoSignatureFieldsToDocument(instance: WebViewerInstance): Promise<string[]> { - if (!instance) { - console.warn("⚠️ WebViewer 인스턴스가 없습니다."); - return []; - } - - try { - const detector = new AutoSignatureFieldDetector(instance); - const createdFields = await detector.detectAndCreateSignatureFields(); - - if (createdFields.length > 0) { - console.log(`🎉 자동 서명 필드 생성 완료: ${createdFields.join(', ')}`); - } - - return createdFields; - - } catch (error) { - console.error("📛 자동 서명 필드 추가 실패:", error); - return []; - } - } - - // ✅ 문서 로드 후 자동 호출되는 Hook - export function useAutoSignatureFields(instance: WebViewerInstance | null) { - const [signatureFields, setSignatureFields] = React.useState<string[]>([]); - const [isProcessing, setIsProcessing] = React.useState(false); - - React.useEffect(() => { - if (!instance) return; - - const { documentViewer } = instance.Core; - - const handleDocumentLoaded = async () => { - try { - setIsProcessing(true); - console.log("📄 문서 로드 완료, 자동 서명 필드 생성 시작..."); - - // 문서 로드 후 잠시 대기 (안정성을 위해) - await new Promise(resolve => setTimeout(resolve, 1000)); - - const fields = await addAutoSignatureFieldsToDocument(instance); - setSignatureFields(fields); - - } catch (error) { - console.error("📛 자동 서명 필드 처리 실패:", error); - } finally { - setIsProcessing(false); - } - }; - - documentViewer.addEventListener('documentLoaded', handleDocumentLoaded); - - return () => { - documentViewer.removeEventListener('documentLoaded', handleDocumentLoaded); - }; - }, [instance]); - - return { - signatureFields, - isProcessing, - hasSignatureFields: signatureFields.length > 0 - }; - }
\ No newline at end of file diff --git a/lib/basic-contract/viewer/basic-contract-sign-viewer.tsx b/lib/basic-contract/viewer/basic-contract-sign-viewer.tsx index 49efb551..b92df089 100644 --- a/lib/basic-contract/viewer/basic-contract-sign-viewer.tsx +++ b/lib/basic-contract/viewer/basic-contract-sign-viewer.tsx @@ -150,12 +150,6 @@ class AutoSignatureFieldDetector { console.log("📄 문서 확인 완료, 기존 필드 검사..."); - // ✅ 3단계: 기존 서명 필드 확인 (안전한 방법) - const existingFields = await this.checkExistingFieldsSafely(); - if (existingFields.length > 0) { - console.log(`✅ 기존 서명 필드 발견: ${existingFields.length}개`); - return existingFields; - } // ✅ 4단계: 단순 기본 서명 필드 생성 (텍스트 분석 스킵) console.log("📝 기본 서명 필드 생성..."); @@ -182,34 +176,7 @@ class AutoSignatureFieldDetector { } } - // ✅ 안전한 기존 필드 확인 (PDFDoc 접근 안함) - private async checkExistingFieldsSafely(): Promise<string[]> { - 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<string> { @@ -229,28 +196,40 @@ class AutoSignatureFieldDetector { // ✅ 간단한 서명 어노테이션 생성 (PDFDoc 접근 없이) const fieldName = `simple_signature_${Date.now()}`; + + const flags = new Annotations.WidgetFlags(); + // flags.set(Annotations.WidgetFlags.REQUIRED, true); + // flags.set(Annotations.WidgetFlags.READ_ONLY, true); + + const formField = new Core.Annotations.Forms.Field( + `SignatureFormField`, + { + type: "Sig", + flags, + } + ); // 서명 위젯 어노테이션 생성 - const signatureWidget = new Annotations.SignatureWidgetAnnotation({ - appearance: Annotations.SignatureWidgetAnnotation.DefaultAppearance.MATERIAL_OUTLINE, + const signatureWidget = new Annotations.SignatureWidgetAnnotation(formField,{ + // appearance: Annotations.SignatureWidgetAnnotation.DefaultAppearance.MATERIAL_OUTLINE, Width: 150, Height: 50 }); // 위치 설정 (마지막 페이지 하단) signatureWidget.setPageNumber(pageCount); - signatureWidget.setX(pageWidth * 0.3); - signatureWidget.setY(pageHeight * 0.15); + signatureWidget.setX(pageWidth * 0.7); + signatureWidget.setY(pageHeight * 0.85); signatureWidget.setWidth(150); signatureWidget.setHeight(50); // 필드명 설정 - signatureWidget.setFieldName(fieldName); - signatureWidget.setCustomData('fieldName', fieldName); + // signatureWidget.setFieldName(fieldName); + // signatureWidget.setCustomData('fieldName', fieldName); - // 스타일 설정 - signatureWidget.StrokeColor = new Annotations.Color(0, 100, 200); // 파란색 - signatureWidget.StrokeThickness = 2; + // // 스타일 설정 + // signatureWidget.StrokeColor = new Annotations.Color(0, 100, 200); // 파란색 + // signatureWidget.StrokeThickness = 2; // 어노테이션 추가 annotationManager.addAnnotation(signatureWidget); @@ -263,47 +242,47 @@ class AutoSignatureFieldDetector { console.error("📛 간단 서명 필드 생성 실패:", error); // ✅ 최후의 수단: 텍스트 어노테이션으로 안내 - return await this.createTextGuidance(); + // return await this.createTextGuidance(); } } // ✅ 최후의 수단: 텍스트 안내 생성 - private async createTextGuidance(): Promise<string> { - try { - const { Core } = this.instance; - const { documentViewer, annotationManager, Annotations } = Core; + // private async createTextGuidance(): Promise<string> { + // 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 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 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); + // const fieldName = `text_guidance_${Date.now()}`; + // textAnnot.setCustomData('fieldName', fieldName); - annotationManager.addAnnotation(textAnnot); - annotationManager.redrawAnnotation(textAnnot); + // annotationManager.addAnnotation(textAnnot); + // annotationManager.redrawAnnotation(textAnnot); - console.log(`✅ 텍스트 안내 생성: ${fieldName}`); - return fieldName; + // console.log(`✅ 텍스트 안내 생성: ${fieldName}`); + // return fieldName; - } catch (error) { - console.error("📛 텍스트 안내 생성도 실패:", error); - return "manual_signature_required"; - } - } + // } catch (error) { + // console.error("📛 텍스트 안내 생성도 실패:", error); + // return "manual_signature_required"; + // } + // } } function useAutoSignatureFields(instance: WebViewerInstance | null) { @@ -692,7 +671,10 @@ useEffect(() => { { path: "/pdftronWeb", licenseKey: process.env.NEXT_PUBLIC_PDFTRON_WEBVIEW_KEY, - fullAPI: true + fullAPI: true , + disabledElements: [ + + ] }, viewerElement ).then((newInstance) => { @@ -737,6 +719,19 @@ useEffect(() => { 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("📛 WebViewer 문서 로딩 에러:", error); @@ -1359,7 +1354,7 @@ const SignatureFieldsStatus = () => { ); }; -// 인라인 뷰어 렌더링 +// 인라인 뷰어 렌더링 부분 수정 if (!isOpen && !onClose) { return ( <div className="h-full w-full flex flex-col overflow-hidden"> @@ -1368,33 +1363,33 @@ if (!isOpen && !onClose) { <div className="border-b bg-gray-50 px-3 py-2 flex-shrink-0"> <SignatureFieldsStatus /> <TabsList className="grid w-full h-8" style={{ gridTemplateColumns: `repeat(${allFiles.length}, 1fr)` }}> - {allFiles.map((file, index) => { - let tabId: string; - if (index === 0) { - tabId = 'main'; - } else if (file.type === 'survey') { - tabId = 'survey'; - } else { - const fileOnlyIndex = allFiles.slice(0, index).filter(f => f.type !== 'survey').length; - tabId = `file-${fileOnlyIndex}`; - } - - return ( - <TabsTrigger key={tabId} value={tabId} className="text-xs"> - <div className="flex items-center space-x-1"> - {file.type === 'survey' ? ( - <ClipboardList className="h-3 w-3" /> - ) : ( - <FileText className="h-3 w-3" /> - )} - <span className="truncate">{file.name}</span> - {file.type === 'survey' && surveyData.completed && ( - <Badge variant="secondary" className="ml-1 h-4 px-1 text-xs">완료</Badge> - )} - </div> - </TabsTrigger> - ); -})} + {allFiles.map((file, index) => { + let tabId: string; + if (index === 0) { + tabId = 'main'; + } else if (file.type === 'survey') { + tabId = 'survey'; + } else { + const fileOnlyIndex = allFiles.slice(0, index).filter(f => f.type !== 'survey').length; + tabId = `file-${fileOnlyIndex}`; + } + + return ( + <TabsTrigger key={tabId} value={tabId} className="text-xs"> + <div className="flex items-center space-x-1"> + {file.type === 'survey' ? ( + <ClipboardList className="h-3 w-3" /> + ) : ( + <FileText className="h-3 w-3" /> + )} + <span className="truncate">{file.name}</span> + {file.type === 'survey' && surveyData.completed && ( + <Badge variant="secondary" className="ml-1 h-4 px-1 text-xs">완료</Badge> + )} + </div> + </TabsTrigger> + ); + })} </TabsList> </div> @@ -1408,37 +1403,55 @@ if (!isOpen && !onClose) { <div className={`absolute inset-0 ${activeTab !== 'survey' ? 'block' : 'hidden'}`} > - <div - ref={viewer} - className="w-full h-full" - style={{ position: 'relative', minHeight: '400px' }} - > - {fileLoading && ( - <div className="absolute inset-0 flex flex-col items-center justify-center bg-white z-10"> - <Loader2 className="h-8 w-8 text-blue-500 animate-spin mb-4" /> - <p className="text-sm text-muted-foreground">문서 로딩 중...</p> - </div> - )} + {/* ✅ 수정: 동일한 구조로 통일하고 스크롤 활성화 */} + <div className="w-full h-full overflow-auto"> + <div + ref={viewer} + className="w-full h-full min-h-[400px]" + style={{ + position: 'relative', + // ✅ WebViewer가 스크롤을 제어하도록 설정 + overflow: 'visible' + }} + > + {fileLoading && ( + <div className="absolute inset-0 flex flex-col items-center justify-center bg-white z-10"> + <Loader2 className="h-8 w-8 text-blue-500 animate-spin mb-4" /> + <p className="text-sm text-muted-foreground">문서 로딩 중...</p> + </div> + )} + </div> </div> </div> </div> </Tabs> ) : ( - <div className="h-full w-full relative"> - <div className="absolute top-2 left-2 z-10"> + // ✅ 수정: Tabs가 없는 경우도 동일한 구조로 변경 + <div className="h-full w-full flex flex-col"> + <div className="flex-shrink-0 p-2"> <SignatureFieldsStatus /> </div> - <div - ref={viewer} - className="absolute inset-0" - style={{ position: 'relative', minHeight: '400px' }} - > - {fileLoading && ( - <div className="absolute inset-0 flex flex-col items-center justify-center bg-white z-10"> - <Loader2 className="h-8 w-8 text-blue-500 animate-spin mb-4" /> - <p className="text-sm text-muted-foreground">문서 로딩 중...</p> + <div className="flex-1 min-h-0 overflow-hidden relative"> + <div className="absolute inset-0"> + <div className="w-full h-full overflow-auto"> + <div + ref={viewer} + className="w-full h-full min-h-[400px]" + style={{ + position: 'relative', + // ✅ WebViewer가 스크롤을 제어하도록 설정 + overflow: 'visible' + }} + > + {fileLoading && ( + <div className="absolute inset-0 flex flex-col items-center justify-center bg-white z-10"> + <Loader2 className="h-8 w-8 text-blue-500 animate-spin mb-4" /> + <p className="text-sm text-muted-foreground">문서 로딩 중...</p> + </div> + )} + </div> </div> - )} + </div> </div> </div> )} @@ -1446,6 +1459,132 @@ if (!isOpen && !onClose) { ); } +// 다이얼로그 뷰어 렌더링 부분도 동일하게 수정 +return ( + <Dialog open={showDialog} onOpenChange={handleClose}> + <DialogContent className="w-[90vw] max-w-6xl h-[90vh] flex flex-col p-0"> + <DialogHeader className="px-6 py-4 border-b flex-shrink-0"> + <DialogTitle className="flex items-center justify-between"> + <span>기본계약서 서명</span> + <SignatureFieldsStatus /> + </DialogTitle> + <DialogDescription> + 계약서를 확인하고 서명을 진행해주세요. + {isComplianceTemplate && ( + <span className="block mt-1 text-amber-600">📋 준법 설문조사를 먼저 완료해주세요.</span> + )} + {isNDATemplate && additionalFiles.length > 0 && ( + <span className="block mt-1 text-blue-600">📎 첨부서류 {additionalFiles.length}개를 각 탭에서 확인해주세요.</span> + )} + {hasSignatureFields && ( + <span className="block mt-1 text-green-600"> + 🎯 서명 위치가 자동으로 감지되었습니다. + {signatureFields.some(f => f.includes('_text')) && ( + <span className="block text-sm text-amber-600"> + 💡 빨간색 텍스트로 표시된 영역을 찾아 서명해주세요. + </span> + )} + {signatureFields.some(f => f.startsWith('default_signature_')) && !signatureFields.some(f => f.includes('_text')) && ( + <span className="block text-sm text-amber-600"> + 💡 마지막 페이지 하단의 핑크색 영역에서 서명해주세요. + </span> + )} + </span> + )} + {autoSignError && ( + <span className="block mt-1 text-red-600">⚠️ 자동 서명 필드 생성 실패 - 수동으로 서명 위치를 클릭해주세요.</span> + )} + </DialogDescription> + </DialogHeader> + + <div className="flex-1 min-h-0 overflow-hidden"> + {allFiles.length > 1 ? ( + <Tabs value={activeTab} onValueChange={handleTabChange} className="h-full flex flex-col"> + <div className="border-b bg-gray-50 px-3 py-2 flex-shrink-0"> + <TabsList className="grid w-full h-8" style={{ gridTemplateColumns: `repeat(${allFiles.length}, 1fr)` }}> + {allFiles.map((file, index) => { + const tabId = index === 0 ? 'main' : file.type === 'survey' ? 'survey' : `file-${index}`; + return ( + <TabsTrigger key={tabId} value={tabId} className="text-xs"> + <div className="flex items-center space-x-1"> + {file.type === 'survey' ? <ClipboardList className="h-3 w-3" /> : <FileText className="h-3 w-3" />} + <span className="truncate">{file.name}</span> + {file.type === 'survey' && surveyData.completed && ( + <Badge variant="secondary" className="ml-1 h-4 px-1 text-xs">완료</Badge> + )} + </div> + </TabsTrigger> + ); + })} + </TabsList> + </div> + + <div className="flex-1 min-h-0 overflow-hidden relative"> + <div className={`absolute inset-0 p-3 ${activeTab === 'survey' ? 'block' : 'hidden'}`}> + <SurveyComponent /> + </div> + + <div className={`absolute inset-0 ${activeTab !== 'survey' ? 'block' : 'hidden'}`}> + {/* ✅ 수정: 스크롤 활성화 */} + <div className="w-full h-full overflow-auto"> + <div + ref={viewer} + className="w-full h-full min-h-[400px]" + style={{ + position: 'relative', + overflow: 'visible' + }} + > + {fileLoading && ( + <div className="absolute inset-0 flex flex-col items-center justify-center bg-white z-10"> + <Loader2 className="h-8 w-8 text-blue-500 animate-spin mb-4" /> + <p className="text-sm text-muted-foreground">문서 로딩 중...</p> + </div> + )} + </div> + </div> + </div> + </div> + </Tabs> + ) : ( + // ✅ 수정: 다이얼로그에서 뷰어만 있는 경우도 동일한 구조 + <div className="h-full flex flex-col"> + <div className="flex-1 min-h-0 overflow-hidden relative"> + <div className="absolute inset-0"> + <div className="w-full h-full overflow-auto"> + <div + ref={viewer} + className="w-full h-full min-h-[400px]" + style={{ + position: 'relative', + overflow: 'visible' + }} + > + {fileLoading && ( + <div className="absolute inset-0 flex flex-col items-center justify-center bg-white z-10"> + <Loader2 className="h-8 w-8 text-blue-500 animate-spin mb-4" /> + <p className="text-sm text-muted-foreground">문서 로딩 중...</p> + </div> + )} + </div> + </div> + </div> + </div> + </div> + )} + </div> + + <DialogFooter className="px-6 py-4 border-t bg-white flex-shrink-0"> + <Button variant="outline" onClick={handleClose} disabled={fileLoading}>취소</Button> + <Button onClick={handleSave} disabled={fileLoading || isAutoSignProcessing}> + <FileSignature className="h-4 w-4 mr-2" /> + 서명 완료 + </Button> + </DialogFooter> + </DialogContent> + </Dialog> +); + // 다이얼로그 뷰어 렌더링 return ( <Dialog open={showDialog} onOpenChange={handleClose}> |
