diff options
| author | dujinkim <dujin.kim@dtsolution.co.kr> | 2025-08-21 06:57:36 +0000 |
|---|---|---|
| committer | dujinkim <dujin.kim@dtsolution.co.kr> | 2025-08-21 06:57:36 +0000 |
| commit | 02b1cf005cf3e1df64183d20ba42930eb2767a9f (patch) | |
| tree | e932c54d5260b0e6fda2b46be2a6ba1c3ee30434 /lib/basic-contract/viewer/basic-contract-sign-viewer.tsx | |
| parent | d78378ecd7ceede1429359f8058c7a99ac34b1b7 (diff) | |
(대표님, 최겸) 설계메뉴추가, 작업사항 업데이트
설계메뉴 - 문서관리
설계메뉴 - 벤더 데이터
gtc 메뉴 업데이트
정보시스템 - 메뉴리스트 및 정보 업데이트
파일 라우트 업데이트
엑셀임포트 개선
기본계약 개선
벤더 가입과정 변경 및 개선
벤더 기본정보 - pq
돌체 오류 수정 및 개선
벤더 로그인 과정 이메일 오류 수정
Diffstat (limited to 'lib/basic-contract/viewer/basic-contract-sign-viewer.tsx')
| -rw-r--r-- | lib/basic-contract/viewer/basic-contract-sign-viewer.tsx | 1679 |
1 files changed, 1506 insertions, 173 deletions
diff --git a/lib/basic-contract/viewer/basic-contract-sign-viewer.tsx b/lib/basic-contract/viewer/basic-contract-sign-viewer.tsx index 8995c560..49efb551 100644 --- a/lib/basic-contract/viewer/basic-contract-sign-viewer.tsx +++ b/lib/basic-contract/viewer/basic-contract-sign-viewer.tsx @@ -1,233 +1,1566 @@ "use client"; import React, { - useState, - useEffect, - useRef, - SetStateAction, - Dispatch, +useState, +useEffect, +useRef, +SetStateAction, +Dispatch, } from "react"; import { WebViewerInstance } from "@pdftron/webviewer"; -import { Loader2 } from "lucide-react"; +import { Loader2, FileText, ClipboardList, AlertTriangle, FileSignature, Target, CheckCircle2 } from "lucide-react"; import { toast } from "sonner"; +import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; import { - Dialog, - DialogContent, - DialogHeader, - DialogTitle, - DialogDescription, - DialogFooter, +Dialog, +DialogContent, +DialogHeader, +DialogTitle, +DialogDescription, +DialogFooter, } from "@/components/ui/dialog"; -import { Button } from "@/components/ui/button"; +import { Label } from "@/components/ui/label"; +import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group"; +import { Checkbox } from "@/components/ui/checkbox"; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; +import { Input } from "@/components/ui/input"; +import { Textarea } from "@/components/ui/textarea"; +import { Upload } from "lucide-react"; + + + +interface FileInfo { +path: string; +name: string; +type: 'main' | 'attachment' | 'survey'; +} interface BasicContractSignViewerProps { - contractId?: number; - filePath?: string; - isOpen?: boolean; - onClose?: () => void; - onSign?: (documentData: ArrayBuffer) => Promise<void>; - instance: WebViewerInstance | null; - setInstance: Dispatch<SetStateAction<WebViewerInstance | null>>; +contractId?: number; +filePath?: string; +additionalFiles?: FileInfo[]; +templateName?: string; +isOpen?: boolean; +onClose?: () => void; +onSign?: (documentData: ArrayBuffer, surveyData?: any) => Promise<void>; +instance: WebViewerInstance | null; +setInstance: Dispatch<SetStateAction<WebViewerInstance | null>>; +t?: (key: string) => string; } -export function BasicContractSignViewer({ - contractId, - filePath, - isOpen = false, - onClose, - onSign, - instance, - setInstance, -}: BasicContractSignViewerProps) { - const [fileLoading, setFileLoading] = useState<boolean>(true); - const viewer = useRef<HTMLDivElement>(null); - const initialized = useRef(false); - const isCancelled = useRef(false); - const [showDialog, setShowDialog] = useState(isOpen); +// ✅ 자동 서명 필드 생성을 위한 타입 정의 +interface SignaturePattern { + regex: RegExp; + name: string; + priority: number; + offsetX?: number; + offsetY?: number; + width?: number; + height?: number; +} - // 다이얼로그 상태 동기화 - useEffect(() => { - setShowDialog(isOpen); - }, [isOpen]); +interface DetectedSignatureLocation { + pageIndex: number; + text: string; + rect: { + x1: number; + y1: number; + x2: number; + y2: number; + }; + pattern: SignaturePattern; + confidence: number; +} - // WebViewer 초기화 - useEffect(() => { - if (!initialized.current && viewer.current) { - initialized.current = true; - isCancelled.current = false; - - requestAnimationFrame(() => { - if (viewer.current) { - import("@pdftron/webviewer").then(({ default: WebViewer }) => { - if (isCancelled.current) { - console.log("📛 WebViewer 초기화 취소됨"); - return; - } +// ✅ 개선된 자동 서명 필드 감지 클래스 - // viewerElement이 확실히 존재함을 확인 - const viewerElement = viewer.current; - if (!viewerElement) return; - - WebViewer( - { - path: "/pdftronWeb", - licenseKey: process.env.NEXT_PUBLIC_PDFTRON_WEBVIEW_KEY, - fullAPI: true, - }, - viewerElement - ).then((instance: WebViewerInstance) => { - setInstance(instance); - setFileLoading(false); - - const { disableElements, setToolbarGroup } = instance.UI; - - disableElements([ - "toolbarGroup-Annotate", - "toolbarGroup-Shapes", - "toolbarGroup-Insert", - "toolbarGroup-Edit", - // "toolbarGroup-FillAndSign", - "toolbarGroup-Forms", - ]); - setToolbarGroup("toolbarGroup-View"); - }); - }); +// ✅ 초간단 안전한 서명 필드 감지 클래스 (새로고침 제거) +class AutoSignatureFieldDetector { + private instance: WebViewerInstance; + private signaturePatterns: SignaturePattern[]; + + constructor(instance: WebViewerInstance) { + this.instance = instance; + this.signaturePatterns = this.initializePatterns(); + } + + private initializePatterns(): SignaturePattern[] { + return [ + // 한국어 패턴들 + { + regex: /서명\s*[::]\s*[_\-\s]{3,}/gi, + name: "한국어_서명_콜론", + priority: 10, + offsetX: 80, + offsetY: -5, + width: 150, + height: 40 + }, + { + regex: /서명란\s*[_\-\s]{0,}/gi, + name: "한국어_서명란", + priority: 9, + offsetX: 60, + offsetY: -5, + width: 150, + height: 40 + }, + // 영어 패턴들 + { + regex: /signature\s*[::]\s*[_\-\s]{3,}/gi, + name: "영어_signature_콜론", + priority: 8, + offsetX: 120, + offsetY: -5, + width: 150, + height: 40 + }, + { + regex: /sign\s+here\s*[::]?\s*[_\-\s]{0,}/gi, + name: "영어_sign_here", + priority: 9, + offsetX: 100, + offsetY: -5, + width: 150, + height: 40 + } + ]; + } + + async detectAndCreateSignatureFields(): Promise<string[]> { + console.log("🔍 안전한 서명 필드 감지 시작..."); + + try { + // ✅ 1단계: 기본 유효성 검사만 + if (!this.instance?.Core?.documentViewer) { + throw new Error("WebViewer 인스턴스가 유효하지 않습니다."); + } + + const { Core } = this.instance; + const { documentViewer } = Core; + + // ✅ 2단계: 문서 존재 확인만 (getPDFDoc 호출 안함) + const document = documentViewer.getDocument(); + if (!document) { + throw new Error("PDF 문서가 로드되지 않았습니다."); + } + + console.log("📄 문서 확인 완료, 기존 필드 검사..."); + + // ✅ 3단계: 기존 서명 필드 확인 (안전한 방법) + const existingFields = await this.checkExistingFieldsSafely(); + if (existingFields.length > 0) { + console.log(`✅ 기존 서명 필드 발견: ${existingFields.length}개`); + return existingFields; + } + + // ✅ 4단계: 단순 기본 서명 필드 생성 (텍스트 분석 스킵) + console.log("📝 기본 서명 필드 생성..."); + const defaultField = await this.createSimpleSignatureField(); + + // ✅ 5단계: 새로고침 없이 완료 + console.log("✅ 서명 필드 생성 완료 (새로고침 스킵)"); + return [defaultField]; + + } catch (error) { + console.error("📛 안전한 서명 필드 생성 실패:", error); + + // 에러 타입별 메시지 + let errorMessage = "서명 필드 생성에 실패했습니다."; + if (error instanceof Error) { + if (error.message.includes("인스턴스")) { + errorMessage = "뷰어가 준비되지 않았습니다."; + } else if (error.message.includes("문서")) { + errorMessage = "문서를 불러오는 중입니다."; } + } + + throw new Error(errorMessage); + } + } + + // ✅ 안전한 기존 필드 확인 (PDFDoc 접근 안함) + private async checkExistingFieldsSafely(): Promise<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> { + try { + const { Core, UI } = this.instance; + const { documentViewer, annotationManager, Annotations } = Core; + + // 페이지 정보 안전하게 가져오기 + const pageCount = documentViewer.getPageCount(); + const lastPageIndex = Math.max(0, pageCount - 1); + + // 페이지 크기 안전하게 가져오기 + const pageWidth = documentViewer.getPageWidth(pageCount) || 612; + const pageHeight = documentViewer.getPageHeight(pageCount) || 792; + + console.log(`📏 페이지 정보: ${pageCount}페이지, 크기 ${pageWidth}x${pageHeight}`); + + // ✅ 간단한 서명 어노테이션 생성 (PDFDoc 접근 없이) + const fieldName = `simple_signature_${Date.now()}`; + + // 서명 위젯 어노테이션 생성 + const signatureWidget = new Annotations.SignatureWidgetAnnotation({ + appearance: Annotations.SignatureWidgetAnnotation.DefaultAppearance.MATERIAL_OUTLINE, + Width: 150, + Height: 50 }); + + // 위치 설정 (마지막 페이지 하단) + signatureWidget.setPageNumber(pageCount); + signatureWidget.setX(pageWidth * 0.3); + signatureWidget.setY(pageHeight * 0.15); + signatureWidget.setWidth(150); + signatureWidget.setHeight(50); + + // 필드명 설정 + signatureWidget.setFieldName(fieldName); + signatureWidget.setCustomData('fieldName', fieldName); + + // 스타일 설정 + signatureWidget.StrokeColor = new Annotations.Color(0, 100, 200); // 파란색 + signatureWidget.StrokeThickness = 2; + + // 어노테이션 추가 + annotationManager.addAnnotation(signatureWidget); + annotationManager.redrawAnnotation(signatureWidget); + + console.log(`✅ 간단 서명 필드 생성: ${fieldName}`); + return fieldName; + + } catch (error) { + console.error("📛 간단 서명 필드 생성 실패:", error); + + // ✅ 최후의 수단: 텍스트 어노테이션으로 안내 + return await this.createTextGuidance(); + } + } + + // ✅ 최후의 수단: 텍스트 안내 생성 + private async createTextGuidance(): Promise<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 textAnnot = new Annotations.FreeTextAnnotation(); + textAnnot.setPageNumber(pageCount); + textAnnot.setX(pageWidth * 0.25); + textAnnot.setY(pageHeight * 0.1); + textAnnot.setWidth(pageWidth * 0.5); + textAnnot.setHeight(60); + textAnnot.setContents("👆 여기를 클릭하여 서명해주세요"); + textAnnot.FontSize = '14pt'; + textAnnot.TextColor = new Annotations.Color(255, 0, 0); // 빨간색 + textAnnot.StrokeColor = new Annotations.Color(255, 200, 200); + textAnnot.FillColor = new Annotations.Color(255, 240, 240); + + const fieldName = `text_guidance_${Date.now()}`; + textAnnot.setCustomData('fieldName', fieldName); + + annotationManager.addAnnotation(textAnnot); + annotationManager.redrawAnnotation(textAnnot); + + console.log(`✅ 텍스트 안내 생성: ${fieldName}`); + return fieldName; + + } catch (error) { + console.error("📛 텍스트 안내 생성도 실패:", error); + return "manual_signature_required"; } + } +} + +function useAutoSignatureFields(instance: WebViewerInstance | null) { + const [signatureFields, setSignatureFields] = useState<string[]>([]); + const [isProcessing, setIsProcessing] = useState(false); + const [error, setError] = useState<string | null>(null); + + // 중복 실행 방지 + const processingRef = useRef(false); + const timeoutRef = useRef<NodeJS.Timeout | null>(null); + + useEffect(() => { + if (!instance) return; + + const { documentViewer } = instance.Core; + + const handleDocumentLoaded = () => { + // ✅ 중복 실행 방지 + if (processingRef.current) { + console.log("📛 이미 처리 중이므로 스킵"); + return; + } + + // ✅ 기존 타이머 정리 + if (timeoutRef.current) { + clearTimeout(timeoutRef.current); + timeoutRef.current = null; + } + + // ✅ 짧은 지연 후 실행 (3초) + timeoutRef.current = setTimeout(async () => { + if (processingRef.current) return; + + processingRef.current = true; + setIsProcessing(true); + setError(null); + + try { + console.log("📄 문서 로드 완료, 안전한 서명 필드 처리 시작..."); + + // ✅ 최종 유효성 검사 + if (!instance?.Core?.documentViewer?.getDocument()) { + throw new Error("문서가 준비되지 않았습니다."); + } + + const detector = new AutoSignatureFieldDetector(instance); + const fields = await detector.detectAndCreateSignatureFields(); + + setSignatureFields(fields); + + // ✅ 결과에 따른 토스트 메시지 + if (fields.length > 0) { + const hasSimpleField = fields.some(field => field.startsWith('simple_signature_')); + const hasTextGuidance = fields.some(field => field.startsWith('text_guidance_')); + const hasManualRequired = fields.includes('manual_signature_required'); + + if (hasSimpleField) { + toast.success("📝 서명 필드가 생성되었습니다.", { + description: "마지막 페이지 하단의 파란색 영역에서 서명해주세요.", + icon: <FileSignature className="h-4 w-4 text-blue-500" />, + duration: 5000 + }); + } else if (hasTextGuidance) { + toast.success("📍 서명 안내가 표시되었습니다.", { + description: "빨간색 텍스트 영역을 클릭하여 서명해주세요.", + icon: <Target className="h-4 w-4 text-red-500" />, + duration: 6000 + }); + } else if (hasManualRequired) { + toast.info("수동 서명이 필요합니다.", { + description: "문서에서 서명할 위치를 직접 클릭해주세요.", + icon: <AlertTriangle className="h-4 w-4 text-amber-500" />, + duration: 5000 + }); + } else { + toast.success(`📋 ${fields.length}개의 서명 필드를 확인했습니다.`, { + description: "기존 서명 필드가 발견되었습니다.", + icon: <CheckCircle2 className="h-4 w-4 text-green-500" />, + duration: 4000 + }); + } + } else { + toast.info("서명 필드 준비 중", { + description: "문서에서 서명할 위치를 클릭해주세요.", + icon: <FileSignature className="h-4 w-4 text-blue-500" />, + duration: 4000 + }); + } + + } catch (error) { + console.error("📛 안전한 서명 필드 처리 실패:", error); + + const errorMessage = error instanceof Error ? error.message : "서명 필드 처리에 실패했습니다."; + setError(errorMessage); + + // ✅ 부드러운 에러 처리 + if (errorMessage.includes("준비")) { + toast.info("문서 로딩 중", { + description: "잠시 후 다시 시도하거나 수동으로 서명해주세요.", + icon: <Loader2 className="h-4 w-4 text-blue-500" /> + }); + } else { + toast.info("수동 서명 모드", { + description: "문서에서 서명할 위치를 직접 클릭해주세요.", + icon: <FileSignature className="h-4 w-4 text-blue-500" /> + }); + } + } finally { + setIsProcessing(false); + processingRef.current = false; + } + }, 3000); // 3초 지연 + }; + + // ✅ 이벤트 리스너 등록 + documentViewer.removeEventListener('documentLoaded', handleDocumentLoaded); + documentViewer.addEventListener('documentLoaded', handleDocumentLoaded); return () => { - if (instance) { - instance.UI.dispose(); + documentViewer.removeEventListener('documentLoaded', handleDocumentLoaded); + + if (timeoutRef.current) { + clearTimeout(timeoutRef.current); + timeoutRef.current = null; } - isCancelled.current = true; - setTimeout(() => cleanupHtmlStyle(), 500); + + processingRef.current = false; }; - }, []); + }, [instance]); - // 문서 로드 + // ✅ 컴포넌트 언마운트 시 정리 useEffect(() => { - if (!instance || !filePath) return; - console.log("📄 파일 로드 시도:", { filePath }); - + return () => { + if (timeoutRef.current) { + clearTimeout(timeoutRef.current); + } + processingRef.current = false; + }; + }, []); + + return { + signatureFields, + isProcessing, + hasSignatureFields: signatureFields.length > 0, + error + }; +} + +export function BasicContractSignViewer({ +contractId, +filePath, +additionalFiles = [], +templateName = "", +isOpen = false, +onClose, +onSign, +instance, +setInstance, +t = (key: string) => key, +}: BasicContractSignViewerProps) { + + console.log("🔍 BasicContractSignViewer props:", { + contractId, + filePath, + additionalFiles, + templateName, + isNDATemplate: templateName.includes('비밀유지') || templateName.includes('NDA') + }); + +const [fileLoading, setFileLoading] = useState<boolean>(true); +const [activeTab, setActiveTab] = useState<string>("main"); +const [surveyData, setSurveyData] = useState<any>({}); +const [surveyAnswers, setSurveyAnswers] = useState<Record<number, any>>({}); +const [surveyTemplate, setSurveyTemplate] = useState<any>(null); +const [surveyLoading, setSurveyLoading] = useState<boolean>(false); +const [uploadedFiles, setUploadedFiles] = useState<Record<number, File[]>>({}); +const [isInitialLoaded, setIsInitialLoaded] = useState<boolean>(false); + +const viewer = useRef<HTMLDivElement>(null); +const initialized = useRef(false); +const isCancelled = useRef(false); +const currentDocumentPath = useRef<string>(""); +const [showDialog, setShowDialog] = useState(isOpen); +const webViewerInstance = useRef<WebViewerInstance | null>(null); + +// ✅ 자동 서명 필드 생성 훅 사용 +const { signatureFields, isProcessing: isAutoSignProcessing, hasSignatureFields, error: autoSignError } = useAutoSignatureFields(webViewerInstance.current || instance); + +// 템플릿 타입 판단 +const isComplianceTemplate = templateName.includes('준법'); +const isNDATemplate = templateName.includes('비밀유지') || templateName.includes('NDA'); + +// 파일 목록 생성 +const allFiles: FileInfo[] = React.useMemo(() => { + const files: FileInfo[] = []; + + if (filePath) { + files.push({ + path: filePath, + name: templateName || "기본 계약서", + type: "main", + }); + } + + const normalizedAttachments: FileInfo[] = (additionalFiles || []) + .map((f: any, idx: number) => ({ + path: f.path ?? f.filePath ?? "", + name: `첨부파일 ${idx + 1}`, + type: "attachment" as const, + })) + .filter(f => !!f.path); + + files.push(...normalizedAttachments); + + if (isComplianceTemplate) { + files.push({ + path: "", + name: "준법 설문조사", + type: "survey", + }); + } + + console.log("📂 생성된 allFiles:", files, { isNDATemplate, isComplianceTemplate }); + return files; +}, [filePath, additionalFiles, templateName, isComplianceTemplate, isNDATemplate]); + +// WebViewer 정리 함수 +const cleanupWebViewer = () => { + console.log("🧹 WebViewer 정리 시작"); + + if (webViewerInstance.current) { + try { + const { documentViewer } = webViewerInstance.current.Core; + if (documentViewer && documentViewer.getDocument()) { + documentViewer.closeDocument(); + } + + if (webViewerInstance.current.UI && typeof webViewerInstance.current.UI.dispose === 'function') { + webViewerInstance.current.UI.dispose(); + } + } catch (error) { + console.warn("WebViewer 정리 중 에러 (무시됨):", error); + } - // filePath를 /api/files/ 엔드포인트를 통해 접근하도록 변환 - // 한글 파일명의 경우 URL 인코딩 처리 + webViewerInstance.current = null; + } + + if (instance && setInstance) { + setInstance(null); + } + + setTimeout(() => cleanupHtmlStyle(), 100); +}; + +// 다이얼로그 및 파일 상태 변경 시 리셋 +useEffect(() => { + setShowDialog(isOpen); + + if (isOpen && isComplianceTemplate && !surveyTemplate) { + loadSurveyTemplate(); + } + + if (isOpen) { + setIsInitialLoaded(false); + currentDocumentPath.current = ""; + console.log("🔄 새로운 계약서 열림, 상태 리셋"); + } +}, [isOpen, isComplianceTemplate]); + +// filePath 변경 시 상태 리셋 및 즉시 문서 로드 +useEffect(() => { + if (!filePath) return; + + console.log("🔄 filePath 변경으로 상태 리셋 및 문서 로드:", filePath); + + setIsInitialLoaded(false); + currentDocumentPath.current = ""; + setActiveTab("main"); + + const currentInstance = webViewerInstance.current || instance; + + if (currentInstance) { const normalizedPath = filePath.startsWith('/') ? filePath.substring(1) : filePath; const encodedPath = normalizedPath.split('/').map(part => encodeURIComponent(part)).join('/'); const apiFilePath = `/api/files/${encodedPath}`; - console.log("📄 파일 로드 시도:", { originalPath: filePath, encodedPath: apiFilePath }); - loadDocument(instance, apiFilePath); - }, [instance, filePath]); + console.log("📄 filePath 변경으로 즉시 문서 로드:", apiFilePath); + + loadDocument(currentInstance, apiFilePath, true).then(() => { + setIsInitialLoaded(true); + console.log("✅ filePath 변경 문서 로드 완료"); + }).catch((error) => { + console.error("📛 filePath 변경 문서 로드 실패:", error); + }); + } +}, [filePath, instance]); - // 간소화된 문서 로드 함수 - const loadDocument = async (instance: WebViewerInstance, documentPath: string) => { - setFileLoading(true); - try { - const { documentViewer } = instance.Core; +const loadSurveyTemplate = async () => { + setSurveyLoading(true); + + const mockTemplate = { + id: 1, + name: '기본 준법 설문조사', + description: '모든 계약업체 대상 기본 준법 설문조사', + questions: [ + { + id: 4, + questionNumber: '4', + questionText: '귀사의 법률적 조직형태는?', + questionType: 'DROPDOWN', + isRequired: true, + hasDetailText: false, + hasFileUpload: false, + options: [ + { id: 1, optionValue: 'COMPANY_CORP', optionText: '주식회사/유한회사' }, + { id: 2, optionValue: 'INDIVIDUAL', optionText: '개인회사' }, + { id: 3, optionValue: 'PARTNERSHIP', optionText: '조합' }, + { id: 4, optionValue: 'JOINT_VENTURE', optionText: '조인트벤처' }, + { id: 5, optionValue: 'OTHER', optionText: '기타', allowsOtherInput: true }, + ] + }, + { + id: 6, + questionNumber: '6', + questionText: '부패방지와 관련한 귀사의 준법정책이 있습니까? 있다면 첨부파일로 제공하여 주시기 바랍니다.', + questionType: 'RADIO', + isRequired: true, + hasDetailText: false, + hasFileUpload: true, + options: [ + { id: 6, optionValue: 'YES', optionText: '네' }, + { id: 7, optionValue: 'NO', optionText: '아니오' }, + ] + }, + { + id: 11, + questionNumber: '11', + questionText: '귀사의 사주, 임원 중에서 전(최근 3년내)·현직 공직자인 사람이 있습니까? 만약 있다면 상세하게 기술해 주십시오.', + questionType: 'RADIO', + isRequired: true, + hasDetailText: true, + hasFileUpload: false, + options: [ + { id: 11, optionValue: 'YES', optionText: '네' }, + { id: 12, optionValue: 'NO', optionText: '아니오' }, + ] + }, + ] + }; + + setSurveyTemplate(mockTemplate); + setSurveyLoading(false); +}; + +// WebViewer 초기화 개선 +useEffect(() => { + if (!initialized.current && viewer.current) { + initialized.current = true; + isCancelled.current = false; + + const initializeWebViewer = () => { + if (!viewer.current || isCancelled.current) { + console.log("📛 WebViewer 초기화 취소됨 (DOM 없음)"); + return; + } + + const viewerElement = viewer.current; - await documentViewer.loadDocument(documentPath, { extension: 'pdf' }); + if (!viewerElement.isConnected) { + console.log("📛 WebViewer DOM이 연결되지 않음, 재시도..."); + setTimeout(initializeWebViewer, 100); + return; + } + + cleanupWebViewer(); + + console.log("📄 WebViewer 초기화 시작..."); - } catch (err) { - console.error("문서 로딩 중 오류 발생:", err); - toast.error("문서를 불러오는데 실패했습니다."); - } finally { - setFileLoading(false); - } + import("@pdftron/webviewer").then(({ default: WebViewer }) => { + if (isCancelled.current || !viewer.current) { + console.log("📛 WebViewer 초기화 취소됨 (import 후)"); + return; + } + + WebViewer( + { + path: "/pdftronWeb", + licenseKey: process.env.NEXT_PUBLIC_PDFTRON_WEBVIEW_KEY, + fullAPI: true + }, + viewerElement + ).then((newInstance) => { + if (isCancelled.current) { + console.log("📛 WebViewer 인스턴스 생성 후 취소됨"); + return; + } + + console.log("📄 WebViewer 초기화 완료"); + + webViewerInstance.current = newInstance; + setInstance(newInstance); + setFileLoading(false); + + const { documentViewer } = newInstance.Core; + const FitMode = newInstance.UI.FitMode; + + // 문서 로드 완료 시 처리 + const handleDocumentLoaded = () => { + setFileLoading(false); + newInstance.UI.setFitMode(FitMode.FitWidth); + + requestAnimationFrame(() => { + try { + documentViewer.refreshAll(); + documentViewer.updateView(); + window.dispatchEvent(new Event("resize")); + setTimeout(() => window.dispatchEvent(new Event("resize")), 100); + } catch (e) { + console.warn("layout refresh skipped", e); + } + }); + }; + + documentViewer.addEventListener('documentLoaded', handleDocumentLoaded); + + documentViewer.addEventListener('layoutChanged', () => { + if (newInstance.UI.getFitMode && newInstance.UI.getFitMode() !== FitMode.Zoom) { + newInstance.UI.setFitMode(FitMode.Zoom); + } + }); + + newInstance.UI.setMinZoomLevel('25%'); + newInstance.UI.setMaxZoomLevel('400%'); + + documentViewer.addEventListener('documentLoadingError', (error) => { + console.error("📛 WebViewer 문서 로딩 에러:", error); + + let showToast = true; + let errorMessage = "문서를 불러오는데 실패했습니다."; + + if (error && typeof error === 'object') { + const errorStr = JSON.stringify(error).toLowerCase(); + + if (errorStr.includes('linearized') || errorStr.includes('getreference')) { + console.warn("⚠️ PDF 구조 경고 (문서 로드는 진행됨)"); + showToast = false; + } else if (errorStr.includes('network')) { + errorMessage = "네트워크 연결을 확인해주세요."; + } else if (errorStr.includes('permission')) { + errorMessage = "문서에 접근할 권한이 없습니다."; + } + } + + if (showToast) { + setFileLoading(false); + toast.error(errorMessage); + } + }); + + }).catch((error) => { + console.error("📛 WebViewer 초기화 실패:", error); + setFileLoading(false); + toast.error("뷰어 초기화에 실패했습니다."); + }); + }).catch((error) => { + console.error("📛 WebViewer 모듈 로드 실패:", error); + setFileLoading(false); + toast.error("뷰어 모듈을 불러오는데 실패했습니다."); + }); + }; + + requestAnimationFrame(() => { + setTimeout(initializeWebViewer, 50); + }); + } + + return () => { + isCancelled.current = true; + cleanupWebViewer(); }; +}, [setInstance]); - // 서명 저장 핸들러 - const handleSave = async () => { - if (!instance) return; +// 확장자 추출 유틸 +const getExtFromPath = (p: string) => { + const m = p.toLowerCase().match(/\.([a-z0-9]+)(?:\?.*)?$/); + return m ? m[1] : undefined; +}; + +// 문서 로드 함수 개선 +const loadDocument = async ( + instance: WebViewerInstance, + documentPath: string, + forceReload = false +) => { + if (!forceReload && currentDocumentPath.current === documentPath) { + console.log("📄 동일한 문서이므로 스킵:", documentPath); + return; + } + + setFileLoading(true); + try { + console.log("📄 문서 로드 시작(UI):", documentPath, forceReload ? "(강제 리로드)" : ""); + + if (!instance || !instance.UI || !instance.Core) { + throw new Error("WebViewer 인스턴스가 유효하지 않습니다."); + } + + const ext = getExtFromPath(documentPath); + await instance.UI.loadDocument(documentPath, { + ...(ext ? { extension: ext } : {}), + filename: documentPath.split("/").pop(), + }); + + currentDocumentPath.current = documentPath; + console.log("📄 문서 로드 완료(UI):", documentPath); + + const { documentViewer } = instance.Core; + requestAnimationFrame(() => { + try { + documentViewer.refreshAll(); + documentViewer.updateView(); + window.dispatchEvent(new Event("resize")); + setTimeout(() => window.dispatchEvent(new Event("resize")), 100); + } catch (e) { + console.warn("레이아웃 새로고침 스킵:", e); + } + }); + } catch (error) { + console.error("📛 문서 로딩 실패(UI):", error); + currentDocumentPath.current = ""; - try { - const { documentViewer } = instance.Core; - const doc = documentViewer.getDocument(); - - // 서명된 문서 데이터 가져오기 - const documentData = await doc.getFileData({ - includeAnnotations: true, - }); - - // 외부에서 제공된 onSign 핸들러가 있으면 호출 - if (onSign) { - await onSign(documentData); - } else { - // 기본 동작 - 서명 성공 메시지 표시 - toast.success("계약서가 성공적으로 서명되었습니다."); + 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 = ""; } - - handleClose(); - } catch (err) { - console.error("서명 저장 중 오류 발생:", err); - toast.error("서명을 저장하는데 실패했습니다."); } - }; + if (msg) toast.error(msg); + } finally { + setFileLoading(false); + } +}; - // 다이얼로그 닫기 핸들러 - const handleClose = () => { - if (onClose) { - onClose(); - } else { - setShowDialog(false); +// 폼 데이터 수집 함수 +const collectFormData = async (instance: WebViewerInstance) => { + try { + const { documentViewer, annotationManager } = instance.Core; + const fieldManager = annotationManager.getFieldManager(); + const fields = fieldManager.getFields(); + + const formData: any = {}; + fields.forEach((field: any) => { + formData[field.name] = field.value; + }); + + console.log('📝 폼 데이터 수집:', formData); + return formData; + } catch (error) { + console.error('📛 폼 데이터 수집 실패:', error); + return {}; + } +}; + +// 탭 변경 핸들러 +const handleTabChange = async (newTab: string) => { + setActiveTab(newTab); + if (newTab === "survey") return; + + const currentInstance = webViewerInstance.current || instance; + if (!currentInstance || fileLoading) return; + + let targetFile: FileInfo | undefined; + if (newTab === "main") { + targetFile = allFiles.find(f => f.type === "main"); + } else if (newTab.startsWith("file-")) { + const fileIndex = parseInt(newTab.replace("file-", ""), 10); + targetFile = allFiles.filter(f => f.type !== 'survey')[fileIndex]; + } + + if (!targetFile?.path) { + console.warn("📛 대상 파일을 찾을 수 없음:", newTab, allFiles); + return; + } + + const normalizedPath = targetFile.path.startsWith("/") + ? targetFile.path.substring(1) + : targetFile.path; + const encodedPath = normalizedPath.split("/").map(encodeURIComponent).join("/"); + const apiFilePath = `/api/files/${encodedPath}`; + + console.log("📄 탭 변경으로 문서 로드:", { newTab, targetFile, apiFilePath }); + + try { + currentDocumentPath.current = ""; + await loadDocument(currentInstance, apiFilePath, true); + setIsInitialLoaded(true); + + const { documentViewer } = currentInstance.Core; + requestAnimationFrame(() => { + try { + documentViewer.refreshAll(); + documentViewer.updateView(); + window.dispatchEvent(new Event("resize")); + } catch (e) { + console.warn("탭 변경 후 레이아웃 새로고침 스킵:", e); + } + }); + } catch (e) { + console.error("📛 탭 변경 실패:", e); + } +}; + +// 초기 메인 문서 로드 개선 +useEffect(() => { + console.log("🔍 초기 로드 체크:", { + hasInstance: !!(webViewerInstance.current || instance), + hasFilePath: !!filePath, + activeTab, + isInitialLoaded, + allFilesLength: allFiles.length, + isNDATemplate + }); + + const currentInstance = webViewerInstance.current || instance; + + if (!currentInstance || !filePath || isInitialLoaded) { + return; + } + + const isMainTab = activeTab === 'main'; + const shouldLoadInitial = allFiles.length === 1 || isMainTab; + + if (!shouldLoadInitial || currentDocumentPath.current !== "") { + return; + } + + const normalizedPath = filePath.startsWith('/') ? filePath.substring(1) : filePath; + const encodedPath = normalizedPath.split('/').map(part => encodeURIComponent(part)).join('/'); + const apiFilePath = `/api/files/${encodedPath}`; + + console.log("📄 초기 마운트 문서 로드:", { apiFilePath, isNDATemplate, activeTab }); + + currentDocumentPath.current = ""; + + loadDocument(currentInstance, apiFilePath, true).then(() => { + setIsInitialLoaded(true); + console.log("✅ 초기 마운트 로드 완료"); + }).catch((error) => { + console.error("📛 초기 마운트 로드 실패:", error); + }); +}, [webViewerInstance.current, instance, filePath, activeTab, isInitialLoaded, allFiles.length, isNDATemplate]); + +// 설문조사 답변 업데이트 함수 +const updateSurveyAnswer = (questionId: number, field: string, value: any) => { + setSurveyAnswers(prev => ({ + ...prev, + [questionId]: { + ...prev[questionId], + questionId, + [field]: value } - }; + })); +}; + +// 파일 업로드 핸들러 +const handleSurveyFileUpload = (questionId: number, files: FileList | null) => { + if (!files) return; + + const fileArray = Array.from(files); + setUploadedFiles(prev => ({ + ...prev, + [questionId]: fileArray + })); + + updateSurveyAnswer(questionId, 'files', fileArray); +}; + +// 질문 완료 여부 체크 +const isSurveyQuestionComplete = (question: any): boolean => { + const answer = surveyAnswers[question.id]; - // 인라인 뷰어 렌더링 (다이얼로그 모드가 아닐 때) - if (!isOpen && !onClose) { + if (!question.isRequired) return true; + if (!answer?.answerValue) return false; + + if (question.hasDetailText && answer.answerValue === 'YES' && !answer.detailText) { + return false; + } + + if (question.hasFileUpload && answer.answerValue === 'YES' && (!answer.files || answer.files.length === 0)) { + return false; + } + + return true; +}; + +// 전체 설문조사 완료 여부 체크 +const isSurveyComplete = (): boolean => { + if (!surveyTemplate?.questions) return false; + return surveyTemplate.questions.every((question: any) => isSurveyQuestionComplete(question)); +}; + +// 설문조사 데이터 처리 +const handleSurveyComplete = async () => { + if (!isSurveyComplete()) { + toast.error('모든 필수 항목을 완료해주세요.', { + description: '미완성된 질문이 있습니다.', + icon: <AlertTriangle className="h-5 w-5 text-red-500" /> + }); + return; + } + + try { + console.log('설문조사 답변:', surveyAnswers); + + setSurveyData({ + completed: true, + answers: Object.values(surveyAnswers), + timestamp: new Date().toISOString() + }); + + toast.success("설문조사가 완료되었습니다!", { + icon: <CheckCircle2 className="h-5 w-5 text-green-500" /> + }); + } catch (error) { + console.error('설문조사 저장 실패:', error); + toast.error('설문조사 저장에 실패했습니다.'); + } +}; + +// 서명 저장 핸들러 +const handleSave = async () => { + const currentInstance = webViewerInstance.current || instance; + if (!currentInstance) return; + + try { + const { documentViewer, annotationManager } = currentInstance.Core; + const doc = documentViewer.getDocument(); + + if (!doc) { + toast.error("문서가 로드되지 않았습니다."); + return; + } + + const formData = await collectFormData(currentInstance); + + const xfdfString = await annotationManager.exportAnnotations(); + const documentData = await doc.getFileData({ + xfdfString, + downloadType: "pdf", + }); + + if (isComplianceTemplate && !surveyData.completed) { + toast.error("준법 설문조사를 먼저 완료해주세요."); + setActiveTab('survey'); + return; + } + + if (onSign) { + await onSign(documentData, { formData, surveyData, signatureFields }); + } else { + toast.success("계약서가 성공적으로 서명되었습니다."); + } + + handleClose(); + } catch (error) { + console.error("📛 서명 저장 실패:", error); + toast.error("서명을 저장하는데 실패했습니다."); + } +}; + +// 다이얼로그 닫기 핸들러 +const handleClose = () => { + if (onClose) { + onClose(); + } else { + setShowDialog(false); + } +}; + +// 동적 설문조사 컴포넌트 +const SurveyComponent = () => { + if (surveyLoading) { return ( - <div className="border rounded-md overflow-hidden" style={{ height: '600px' }}> - <div ref={viewer} className="h-[100%]"> - {fileLoading && ( - <div className="flex flex-col items-center justify-center py-12"> - <Loader2 className="h-8 w-8 text-blue-500 animate-spin mb-4" /> - <p className="text-sm text-muted-foreground">문서 로딩 중...</p> + <div className="h-full w-full"> + <Card className="h-full"> + <CardContent className="flex flex-col items-center justify-center h-full py-12"> + <Loader2 className="h-8 w-8 text-blue-500 animate-spin mb-4" /> + <p className="text-sm text-muted-foreground">설문조사를 불러오는 중...</p> + </CardContent> + </Card> + </div> + ); + } + + if (!surveyTemplate) { + return ( + <div className="h-full w-full"> + <Card className="h-full"> + <CardContent className="flex flex-col items-center justify-center h-full py-12"> + <AlertTriangle className="h-8 w-8 text-red-500 mb-4" /> + <p className="text-sm text-muted-foreground">설문조사 템플릿을 불러올 수 없습니다.</p> + <Button + variant="outline" + onClick={loadSurveyTemplate} + className="mt-2" + > + 다시 시도 + </Button> + </CardContent> + </Card> + </div> + ); + } + + const completedCount = surveyTemplate.questions.filter((q: any) => isSurveyQuestionComplete(q)).length; + const progressPercentage = surveyTemplate.questions.length > 0 ? (completedCount / surveyTemplate.questions.length) * 100 : 0; + +const renderSurveyQuestion = (question: any) => { + const answer = surveyAnswers[question.id]; + const isComplete = isSurveyQuestionComplete(question); + + return ( + <div key={question.id} className="mb-6 p-4 border rounded-lg bg-gray-50"> + <div className="flex items-start justify-between mb-3"> + <div className="flex-1"> + <Label className="text-sm font-medium text-gray-900 flex items-center"> + <span className="mr-2 px-2 py-1 bg-blue-100 text-blue-700 rounded text-xs"> + {question.questionNumber} + </span> + {question.questionText} + {question.isRequired && <span className="text-red-500 ml-1">*</span>} + </Label> + </div> + {isComplete && ( + <CheckCircle2 className="h-5 w-5 text-green-500 ml-2" /> + )} + </div> + + {question.questionType === 'RADIO' && ( + <RadioGroup + value={answer?.answerValue || ''} + onValueChange={(value) => updateSurveyAnswer(question.id, 'answerValue', value)} + className="space-y-2" + > + {question.options?.map((option: any) => ( + <div key={option.id} className="flex items-center space-x-2"> + <RadioGroupItem value={option.optionValue} id={`${question.id}-${option.id}`} /> + <Label htmlFor={`${question.id}-${option.id}`} className="text-sm"> + {option.optionText} + </Label> </div> + ))} + </RadioGroup> + )} + + {question.questionType === 'DROPDOWN' && ( + <div className="space-y-2"> + <Select + value={answer?.answerValue || ''} + onValueChange={(value) => updateSurveyAnswer(question.id, 'answerValue', value)} + > + <SelectTrigger> + <SelectValue placeholder="선택해주세요" /> + </SelectTrigger> + <SelectContent> + {question.options?.map((option: any) => ( + <SelectItem key={option.id} value={option.optionValue}> + {option.optionText} + </SelectItem> + ))} + </SelectContent> + </Select> + + {answer?.answerValue === 'OTHER' && ( + <Input + placeholder="기타 내용을 입력해주세요" + value={answer?.otherText || ''} + onChange={(e) => updateSurveyAnswer(question.id, 'otherText', e.target.value)} + className="mt-2" + /> )} </div> - </div> - ); + )} + + {question.questionType === 'TEXTAREA' && ( + <Textarea + placeholder="상세한 내용을 입력해주세요" + value={answer?.detailText || ''} + onChange={(e) => updateSurveyAnswer(question.id, 'detailText', e.target.value)} + rows={4} + /> + )} + + {question.hasDetailText && answer?.answerValue === 'YES' && ( + <div className="mt-3"> + <Label className="text-sm text-gray-700 mb-2 block">상세 내용을 기술해주세요:</Label> + <Textarea + placeholder="상세한 내용을 입력해주세요" + value={answer?.detailText || ''} + onChange={(e) => updateSurveyAnswer(question.id, 'detailText', e.target.value)} + rows={3} + className="w-full" + /> + </div> + )} + + {question.hasFileUpload && answer?.answerValue === 'YES' && ( + <div className="mt-3"> + <Label className="text-sm text-gray-700 mb-2 block">첨부파일:</Label> + <div className="border-2 border-dashed border-gray-300 rounded-lg p-4"> + <input + type="file" + multiple + onChange={(e) => handleSurveyFileUpload(question.id, e.target.files)} + className="hidden" + id={`file-${question.id}`} + /> + <label htmlFor={`file-${question.id}`} className="cursor-pointer"> + <div className="flex flex-col items-center"> + <Upload className="h-8 w-8 text-gray-400 mb-2" /> + <span className="text-sm text-gray-500">파일을 선택하거나 여기에 드래그하세요</span> + </div> + </label> + + {uploadedFiles[question.id] && uploadedFiles[question.id].length > 0 && ( + <div className="mt-3 space-y-1"> + {uploadedFiles[question.id].map((file, index) => ( + <div key={index} className="flex items-center space-x-2 text-sm"> + <FileText className="h-4 w-4 text-blue-500" /> + <span>{file.name}</span> + <span className="text-gray-500">({(file.size / 1024).toFixed(1)} KB)</span> + </div> + ))} + </div> + )} + </div> + </div> + )} + </div> + ); +}; + + return ( + <div className="h-full w-full flex flex-col"> + <Card className="h-full flex flex-col"> + <CardHeader className="flex-shrink-0"> + <CardTitle className="flex items-center justify-between"> + <div className="flex items-center"> + <ClipboardList className="h-5 w-5 mr-2 text-amber-500" /> + {surveyTemplate.name} + </div> + <div className="text-sm text-gray-500"> + {completedCount}/{surveyTemplate.questions.length} 완료 + </div> + </CardTitle> + <CardDescription> + {surveyTemplate.description} + </CardDescription> + + <div className="w-full bg-gray-200 rounded-full h-2"> + <div + className="bg-blue-600 h-2 rounded-full transition-all duration-300" + style={{ width: `${progressPercentage}%` }} + /> + </div> + </CardHeader> + + <CardContent className="flex-1 min-h-0 overflow-y-auto"> + <div className="space-y-6"> + <div className="p-4 border rounded-lg bg-yellow-50"> + <div className="flex items-start"> + <AlertTriangle className="h-5 w-5 text-yellow-600 mt-0.5 mr-2" /> + <div> + <p className="font-medium text-yellow-800">중요 안내</p> + <p className="text-sm text-yellow-700 mt-1"> + 본 설문조사는 준법 의무 확인을 위한 필수 절차입니다. 모든 항목을 정확히 작성해주세요. + </p> + </div> + </div> + </div> + + <div className="space-y-4"> + {surveyTemplate.questions.map((question: any) => renderSurveyQuestion(question))} + </div> + + <div className="flex justify-end pt-6 border-t"> + <Button + onClick={handleSurveyComplete} + disabled={!isSurveyComplete()} + className="bg-blue-600 hover:bg-blue-700" + > + <CheckCircle2 className="h-4 w-4 mr-2" /> + 설문조사 완료 + </Button> + </div> + </div> + </CardContent> + </Card> + </div> + ); +}; + +// 디버깅을 위한 useEffect +useEffect(() => { + if (isNDATemplate) { + console.log("🔍 NDA 템플릿 디버깅:", { + filePath, + additionalFiles, + allFiles, + activeTab, + isInitialLoaded, + currentDocumentPath: currentDocumentPath.current, + hasWebViewerInstance: !!webViewerInstance.current, + hasParentInstance: !!instance, + signatureFields, + hasSignatureFields, + isAutoSignProcessing, + autoSignError + }); } +}, [isNDATemplate, filePath, additionalFiles, allFiles, activeTab, isInitialLoaded, signatureFields, hasSignatureFields, isAutoSignProcessing, autoSignError]); + +// ✅ 서명 필드 상태 표시 컴포넌트 +const SignatureFieldsStatus = () => { + if (!hasSignatureFields && !isAutoSignProcessing && !autoSignError) return null; - // 다이얼로그 뷰어 렌더링 return ( - <Dialog open={showDialog} onOpenChange={handleClose}> - <DialogContent className="w-[70vw]" style={{ maxWidth: "none" }}> - <DialogHeader> - <DialogTitle>기본계약서 서명</DialogTitle> - <DialogDescription> - 계약서를 확인하고 서명을 진행해주세요. - </DialogDescription> - </DialogHeader> - <div className="h-[calc(70vh-60px)]"> - <div ref={viewer} className="h-[100%]"> + <div className="mb-2"> + {isAutoSignProcessing ? ( + <Badge variant="secondary" className="text-xs"> + <Loader2 className="h-3 w-3 mr-1 animate-spin" /> + 서명 필드 생성 중... + </Badge> + ) : autoSignError ? ( + <Badge variant="destructive" className="text-xs bg-red-50 text-red-700 border-red-200"> + <AlertTriangle className="h-3 w-3 mr-1" /> + 자동 생성 실패 + </Badge> + ) : hasSignatureFields ? ( + <Badge variant="outline" className="text-xs bg-blue-50 text-blue-700 border-blue-200"> + <Target className="h-3 w-3 mr-1" /> + {signatureFields.length}개 서명 필드 자동 생성됨 + </Badge> + ) : null} + </div> + ); +}; + +// 인라인 뷰어 렌더링 +if (!isOpen && !onClose) { + return ( + <div className="h-full w-full flex flex-col 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"> + <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> + ); +})} + </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 + 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> + </div> + </div> + </Tabs> + ) : ( + <div className="h-full w-full relative"> + <div className="absolute top-2 left-2 z-10"> + <SignatureFieldsStatus /> + </div> + <div + ref={viewer} + className="absolute inset-0" + style={{ position: 'relative', minHeight: '400px' }} + > {fileLoading && ( - <div className="flex flex-col items-center justify-center py-12"> + <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> - <DialogFooter> - <Button variant="outline" onClick={handleClose} disabled={fileLoading}> - 취소 - </Button> - <Button onClick={handleSave} disabled={fileLoading}> - 서명 완료 - </Button> - </DialogFooter> - </DialogContent> - </Dialog> + )} + </div> ); } +// 다이얼로그 뷰어 렌더링 +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 + 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> + </div> + </div> + </Tabs> + ) : ( + <div className="h-full relative"> + <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> + )} + </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> +); +} + // WebViewer 정리 함수 const cleanupHtmlStyle = () => { - // iframe 스타일 정리 (WebViewer가 추가한 스타일) - const elements = document.querySelectorAll('.Document_container'); - elements.forEach((elem) => { - elem.remove(); - }); +const elements = document.querySelectorAll('.Document_container'); +elements.forEach((elem) => { + elem.remove(); +}); };
\ No newline at end of file |
