summaryrefslogtreecommitdiff
path: root/lib/basic-contract/viewer/basic-contract-sign-viewer.tsx
diff options
context:
space:
mode:
Diffstat (limited to 'lib/basic-contract/viewer/basic-contract-sign-viewer.tsx')
-rw-r--r--lib/basic-contract/viewer/basic-contract-sign-viewer.tsx1679
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