summaryrefslogtreecommitdiff
path: root/lib/basic-contract/viewer/basic-contract-sign-viewer.tsx
diff options
context:
space:
mode:
authordujinkim <dujin.kim@dtsolution.co.kr>2025-08-27 12:06:26 +0000
committerdujinkim <dujin.kim@dtsolution.co.kr>2025-08-27 12:06:26 +0000
commit7548e2ad6948f1c6aa102fcac408bc6c9c0f9796 (patch)
tree8e66703ec821888ad51dcc242a508813a027bf71 /lib/basic-contract/viewer/basic-contract-sign-viewer.tsx
parent7eac558470ef179dad626a8e82db5784fe86a556 (diff)
(대표님, 최겸) 기본계약, 입찰, 파일라우트, 계약서명라우트, 인포메이션, 메뉴설정, PQ(메일템플릿 관련)
Diffstat (limited to 'lib/basic-contract/viewer/basic-contract-sign-viewer.tsx')
-rw-r--r--lib/basic-contract/viewer/basic-contract-sign-viewer.tsx1515
1 files changed, 400 insertions, 1115 deletions
diff --git a/lib/basic-contract/viewer/basic-contract-sign-viewer.tsx b/lib/basic-contract/viewer/basic-contract-sign-viewer.tsx
index fbf36738..943878da 100644
--- a/lib/basic-contract/viewer/basic-contract-sign-viewer.tsx
+++ b/lib/basic-contract/viewer/basic-contract-sign-viewer.tsx
@@ -6,16 +6,13 @@ import React, {
useRef,
SetStateAction,
Dispatch,
- useMemo,
- useCallback,
} from "react";
import { WebViewerInstance } from "@pdftron/webviewer";
-import { Loader2, FileText, ClipboardList, AlertTriangle, FileSignature, Target, CheckCircle2 } from "lucide-react";
+import { Loader2, FileText, ClipboardList, AlertTriangle, FileSignature, Target, CheckCircle2, BookOpen } from "lucide-react";
import { toast } from "sonner";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
-import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import {
Dialog,
DialogContent,
@@ -24,20 +21,15 @@ import {
DialogDescription,
DialogFooter,
} from "@/components/ui/dialog";
-import { Label } from "@/components/ui/label";
-import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
-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";
-import { CompleteSurveyRequest, SurveyAnswerData, completeSurvey, getActiveSurveyTemplate, type SurveyTemplateWithQuestions } from '../service';
-import { ConditionalSurveyHandler, useConditionalSurvey } from '../vendor-table/survey-conditional';
-import { useForm, useWatch, Controller } from "react-hook-form";
+import { getActiveSurveyTemplate, getVendorSignatureFile, type SurveyTemplateWithQuestions } from '../service';
+import { useConditionalSurvey } from '../vendor-table/survey-conditional';
+import { SurveyComponent } from './SurveyComponent';
+import { GtcClausesComponent } from './GtcClausesComponent';
interface FileInfo {
path: string;
name: string;
- type: 'main' | 'attachment' | 'survey';
+ type: 'main' | 'attachment' | 'survey' | 'clauses';
}
interface BasicContractSignViewerProps {
@@ -50,21 +42,13 @@ interface BasicContractSignViewerProps {
onSign?: (documentData: ArrayBuffer, surveyData?: any) => Promise<void>;
instance: WebViewerInstance | null;
setInstance: Dispatch<SetStateAction<WebViewerInstance | null>>;
- onSurveyComplete?: () => void; // 🔥 새로 추가
- onSignatureComplete?: () => void; // 🔥 새로 추가
+ onSurveyComplete?: () => void;
+ onSignatureComplete?: () => void;
+ onGtcCommentStatusChange?: (hasComments: boolean, commentCount: number) => void;
+ mode?: 'vendor' | 'buyer'; // 추가된 mode prop
t?: (key: string) => string;
}
-// 폼 데이터 타입 정의
-interface SurveyFormData {
- [key: string]: {
- answerValue?: string;
- detailText?: string;
- otherText?: string;
- files?: File[];
- };
-}
-
// 자동 서명 필드 생성을 위한 타입 정의
interface SignaturePattern {
regex: RegExp;
@@ -80,9 +64,11 @@ interface SignaturePattern {
class AutoSignatureFieldDetector {
private instance: WebViewerInstance;
private signaturePatterns: SignaturePattern[];
+ private mode: 'vendor' | 'buyer'; // mode 추가
- constructor(instance: WebViewerInstance) {
+ constructor(instance: WebViewerInstance, mode: 'vendor' | 'buyer' = 'vendor') {
this.instance = instance;
+ this.mode = mode;
this.signaturePatterns = this.initializePatterns();
}
@@ -128,7 +114,7 @@ class AutoSignatureFieldDetector {
}
async detectAndCreateSignatureFields(): Promise<string[]> {
- console.log("🔍 안전한 서명 필드 감지 시작...");
+ console.log(`🔍 안전한 서명 필드 감지 시작... (모드: ${this.mode})`);
try {
if (!this.instance?.Core?.documentViewer) {
@@ -166,77 +152,87 @@ class AutoSignatureFieldDetector {
}
private async createSimpleSignatureField(): Promise<string> {
- try {
- const { Core } = this.instance;
- const { documentViewer, annotationManager, Annotations } = Core;
+ 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 page = documentViewer.getPageCount();
+ const w = documentViewer.getPageWidth(page) || 612;
+ const h = documentViewer.getPageHeight(page) || 792;
- console.log(`📏 페이지 정보: ${pageCount}페이지, 크기 ${pageWidth}x${pageHeight}`);
-
- const fieldName = `simple_signature_${Date.now()}`;
- const flags = new Annotations.WidgetFlags();
-
- const formField = new Core.Annotations.Forms.Field(
- `SignatureFormField`,
- {
- type: "Sig",
- flags,
- }
- );
-
- const signatureWidget = new Annotations.SignatureWidgetAnnotation(formField, {
- Width: 150,
- Height: 50
- });
+ const fieldName = `simple_signature_${Date.now()}`;
+ const flags = new Annotations.WidgetFlags();
+ flags.set('Required', true);
- signatureWidget.setPageNumber(pageCount);
- signatureWidget.setX(pageWidth * 0.7);
- signatureWidget.setY(pageHeight * 0.85);
- signatureWidget.setWidth(150);
- signatureWidget.setHeight(50);
+ const field = new Core.Annotations.Forms.Field(fieldName, { type: 'Sig', flags });
- annotationManager.addAnnotation(signatureWidget);
- annotationManager.redrawAnnotation(signatureWidget);
+ const widget = new Annotations.SignatureWidgetAnnotation(field, { Width: 150, Height: 50 });
+ widget.setPageNumber(page);
+
+ // 구매자 모드일 때는 왼쪽 하단으로 위치 설정
+ if (this.mode === 'buyer') {
+ widget.setX(w * 0.1); // 왼쪽 (10%)
+ widget.setY(h * 0.85); // 하단 (85%)
+ } else {
+ // 협력업체 모드일 때는 기존처럼 오른쪽
+ widget.setX(w * 0.7); // 오른쪽 (70%)
+ widget.setY(h * 0.85); // 하단 (85%)
+ }
+
+ widget.setWidth(150);
+ widget.setHeight(50);
- console.log(`✅ 서명 필드 생성: ${fieldName}`);
- return fieldName;
+ const fm = annotationManager.getFieldManager();
+ fm.addField(field);
+ annotationManager.addAnnotation(widget);
+ annotationManager.drawAnnotationsFromList([widget]);
- } catch (error) {
- console.error("📛 서명 필드 생성 실패:", error);
- return "manual_signature_required";
- }
+ return fieldName;
}
}
-function useAutoSignatureFields(instance: WebViewerInstance | null) {
+function useAutoSignatureFields(instance: WebViewerInstance | null, mode: 'vendor' | 'buyer' = 'vendor') {
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);
+ const processedDocumentRef = useRef<string | null>(null); // 처리된 문서 추적
+ const handlerRef = useRef<(() => void) | null>(null); // 핸들러 참조 저장
useEffect(() => {
if (!instance) return;
const { documentViewer } = instance.Core;
+ // 새로운 핸들러 생성 (참조가 변하지 않도록)
const handleDocumentLoaded = () => {
+ // 현재 문서의 고유 식별자 생성
+ const currentDoc = documentViewer.getDocument();
+ const documentId = currentDoc ? `${currentDoc.getFilename()}_${Date.now()}` : null;
+
+ // 같은 문서를 이미 처리했다면 스킵
+ if (documentId && processedDocumentRef.current === documentId) {
+ console.log("📛 이미 처리된 문서이므로 스킵:", documentId);
+ return;
+ }
+
if (processingRef.current) {
console.log("📛 이미 처리 중이므로 스킵");
return;
}
+ // 이전 타이머 클리어
if (timeoutRef.current) {
clearTimeout(timeoutRef.current);
timeoutRef.current = null;
}
timeoutRef.current = setTimeout(async () => {
+ // 다시 한번 중복 체크
if (processingRef.current) return;
+ if (documentId && processedDocumentRef.current === documentId) return;
processingRef.current = true;
setIsProcessing(true);
@@ -249,32 +245,58 @@ function useAutoSignatureFields(instance: WebViewerInstance | null) {
throw new Error("문서가 준비되지 않았습니다.");
}
- const detector = new AutoSignatureFieldDetector(instance);
+ // 기존 서명 필드 확인
+ const { annotationManager } = instance.Core;
+ const existingAnnotations = annotationManager.getAnnotationsList();
+ const existingSignatureFields = existingAnnotations.filter(
+ annot => annot instanceof instance.Core.Annotations.SignatureWidgetAnnotation
+ );
+
+ // 이미 서명 필드가 있으면 생성하지 않음
+ if (existingSignatureFields.length > 0) {
+ console.log("📋 기존 서명 필드 발견:", existingSignatureFields.length);
+ const fieldNames = existingSignatureFields.map((field, idx) =>
+ field.getField()?.name || `existing_signature_${idx}`
+ );
+ setSignatureFields(fieldNames);
+
+ // 처리 완료 표시
+ if (documentId) {
+ processedDocumentRef.current = documentId;
+ }
+
+ toast.success(`📋 ${fieldNames.length}개의 기존 서명 필드를 확인했습니다.`);
+ return;
+ }
+
+ const detector = new AutoSignatureFieldDetector(instance, mode); // mode 전달
const fields = await detector.detectAndCreateSignatureFields();
setSignatureFields(fields);
+ // 처리 완료 표시
+ if (documentId) {
+ processedDocumentRef.current = documentId;
+ }
+
if (fields.length > 0) {
const hasSimpleField = fields.some(field => field.startsWith('simple_signature_'));
if (hasSimpleField) {
+ const positionMessage = mode === 'buyer'
+ ? "마지막 페이지 왼쪽 하단의 파란색 영역에서 서명해주세요."
+ : "마지막 페이지 하단의 파란색 영역에서 서명해주세요.";
+
toast.success("📝 서명 필드가 생성되었습니다.", {
- description: "마지막 페이지 하단의 파란색 영역에서 서명해주세요.",
+ description: positionMessage,
icon: <FileSignature className="h-4 w-4 text-blue-500" />,
duration: 5000
});
- } else {
- toast.success(`📋 ${fields.length}개의 서명 필드를 확인했습니다.`, {
- description: "기존 서명 필드가 발견되었습니다.",
- icon: <CheckCircle2 className="h-4 w-4 text-green-500" />,
- duration: 4000
- });
}
}
} catch (error) {
console.error("📛 서명 필드 처리 실패:", error);
-
const errorMessage = error instanceof Error ? error.message : "서명 필드 처리에 실패했습니다.";
setError(errorMessage);
@@ -289,27 +311,53 @@ function useAutoSignatureFields(instance: WebViewerInstance | null) {
}, 3000);
};
- documentViewer.removeEventListener('documentLoaded', handleDocumentLoaded);
+ // 핸들러 참조 저장
+ handlerRef.current = handleDocumentLoaded;
+
+ // 이전 리스너 제거 (저장된 참조 사용)
+ if (handlerRef.current) {
+ documentViewer.removeEventListener('documentLoaded', handlerRef.current);
+ }
+
+ // 새 리스너 등록
documentViewer.addEventListener('documentLoaded', handleDocumentLoaded);
+ // 이미 문서가 로드되어 있다면 즉시 실행
+ if (documentViewer.getDocument()) {
+ // 짧은 지연 후 실행 (WebViewer 초기화 완료 보장)
+ setTimeout(() => {
+ if (!processingRef.current) {
+ handleDocumentLoaded();
+ }
+ }, 1000);
+ }
+
return () => {
- documentViewer.removeEventListener('documentLoaded', handleDocumentLoaded);
+ // 클리어 시 저장된 참조 사용
+ if (handlerRef.current) {
+ documentViewer.removeEventListener('documentLoaded', handlerRef.current);
+ }
if (timeoutRef.current) {
clearTimeout(timeoutRef.current);
timeoutRef.current = null;
}
+ // 상태 리셋
processingRef.current = false;
+ processedDocumentRef.current = null;
+ handlerRef.current = null;
};
- }, [instance]);
+ }, [instance, mode]); // mode 의존성 추가
+ // 컴포넌트 언마운트 시 정리
useEffect(() => {
return () => {
if (timeoutRef.current) {
clearTimeout(timeoutRef.current);
}
processingRef.current = false;
+ processedDocumentRef.current = null;
};
}, []);
@@ -320,198 +368,121 @@ function useAutoSignatureFields(instance: WebViewerInstance | null) {
error
};
}
-
-// 🔥 서명 감지를 위한 커스텀 훅 수정
+// XFDF 기반 서명 감지
function useSignatureDetection(instance: WebViewerInstance | null, onSignatureComplete?: () => void) {
const [hasValidSignature, setHasValidSignature] = useState(false);
- const checkIntervalRef = useRef<NodeJS.Timeout | null>(null);
- const lastSignatureStateRef = useRef(false);
- const onSignatureCompleteRef = useRef(onSignatureComplete);
+ const onCompleteRef = useRef(onSignatureComplete);
- // 콜백 레퍼런스 업데이트
useEffect(() => {
- onSignatureCompleteRef.current = onSignatureComplete;
+ onCompleteRef.current = onSignatureComplete
}, [onSignatureComplete]);
- const checkSignatureFields = useCallback(async () => {
- if (!instance?.Core?.annotationManager) {
- console.log('🔍 서명 체크: annotationManager 없음');
- return false;
- }
-
- try {
- const { annotationManager, documentViewer } = instance.Core;
-
- // 문서가 로드되지 않았으면 false 반환
- if (!documentViewer.getDocument()) {
- console.log('🔍 서명 체크: 문서 미로드');
- return false;
- }
+ useEffect(() => {
+ if (!instance?.Core) return;
- let hasSignature = false;
+ const { annotationManager, documentViewer } = instance.Core;
+ const checkSignedByAppearance = () => {
+ try {
+ const annotations = annotationManager.getAnnotationsList();
+ const signatureWidgets = annotations.filter(
+ annot => annot instanceof instance.Core.Annotations.SignatureWidgetAnnotation
+ );
- // 1. Form Fields 확인 (더 정확한 방법)
- const fieldManager = annotationManager.getFieldManager();
- const fields = fieldManager.getFields();
-
- console.log('🔍 폼 필드 확인:', fields.map(field => ({
- name: field.name,
- type: field.type,
- value: field.value,
- hasValue: !!field.value
- })));
-
- // 서명 필드 확인
- for (const field of fields) {
- // PDFTron에서 서명 필드는 보통 'Sig' 타입이지만, 값이 있는지 정확히 확인
- if (field.type === 'Sig' || field.name?.toLowerCase().includes('signature')) {
- if (field.value && (
- typeof field.value === 'string' && field.value.length > 0 ||
- typeof field.value === 'object' && field.value !== null
- )) {
- hasSignature = true;
- console.log('🔍 서명 필드에서 서명 발견:', field.name, field.value);
- break;
- }
+ if (signatureWidgets.length === 0) {
+ return false;
}
- }
- // 2. Signature Widget Annotations 확인
- if (!hasSignature) {
- const annotations = annotationManager.getAnnotationsList();
- console.log('🔍 주석 확인:', annotations.length, '개');
-
- for (const annotation of annotations) {
- // SignatureWidgetAnnotation 타입 확인
- if (annotation.elementName === 'signatureWidget' ||
- annotation.constructor.name === 'SignatureWidgetAnnotation' ||
- annotation.Subject === 'Signature') {
-
- // 서명 데이터가 있는지 확인
- const hasSignatureData = annotation.getImageData && annotation.getImageData() ||
- annotation.getPath && annotation.getPath() ||
- annotation.getCustomData && annotation.getCustomData('signature-data');
-
- if (hasSignatureData) {
- hasSignature = true;
- console.log('🔍 서명 위젯에서 서명 발견:', annotation);
- break;
- }
+ for (const widget of signatureWidgets) {
+ const isSignedByAppearance = widget.isSignedByAppearance();
+
+ if (isSignedByAppearance) {
+ return true;
}
}
+ return false;
+
+ } catch (error) {
+ console.error('서명 위젯 확인 중 오류:', error);
+ return false;
}
+ };
- // 3. Ink/FreeHand Annotations 확인 (직접 그린 서명)
- if (!hasSignature) {
- const annotations = annotationManager.getAnnotationsList();
-
- for (const annotation of annotations) {
- if (annotation.elementName === 'freeHand' ||
- annotation.elementName === 'ink' ||
- annotation.constructor.name === 'FreeHandAnnotation') {
-
- // 경로 데이터가 있으면 서명으로 간주
- const hasPath = annotation.getPath && annotation.getPath().length > 0;
- if (hasPath) {
- hasSignature = true;
- console.log('🔍 자유 그리기에서 서명 발견:', annotation);
- break;
- }
+ const checkSigned = async () => {
+ try {
+ const hasSignature = await checkSignedByAppearance();
+
+ if (hasSignature !== hasValidSignature) {
+ setHasValidSignature(hasSignature);
+
+ if (hasSignature && onCompleteRef.current) {
+ onCompleteRef.current();
}
}
+
+ } catch (error) {
+ console.error('서명 확인 중 오류:', error);
}
+ };
- console.log('🔍 최종 서명 감지 결과:', {
- hasSignature,
- fieldsCount: fields.length,
- annotationsCount: annotationManager.getAnnotationsList().length
- });
-
- return hasSignature;
- } catch (error) {
- console.error('📛 서명 확인 중 에러:', error);
- return false;
- }
- }, [instance]);
+ const onAnnotationChanged = (annotations: any[], action: string) => {
+ console.log("서명 변경")
+ // if (action === 'delete') return;
- // 실시간 서명 감지 (무한 렌더링 방지)
- useEffect(() => {
- if (!instance?.Core) return;
+ setTimeout(checkSigned, 800);
+ };
- const startMonitoring = () => {
- // 기존 인터벌 정리
- if (checkIntervalRef.current) {
- clearInterval(checkIntervalRef.current);
- checkIntervalRef.current = null;
- }
- console.log('🔍 서명 모니터링 시작');
- // 2초마다 서명 상태 확인 (1초보다 간격을 늘려 성능 개선)
- checkIntervalRef.current = setInterval(async () => {
- try {
- const hasSignature = await checkSignatureFields();
-
- // 상태가 실제로 변경되었을 때만 업데이트
- if (hasSignature !== lastSignatureStateRef.current) {
- console.log('🔍 서명 상태 변경:', lastSignatureStateRef.current, '->', hasSignature);
-
- lastSignatureStateRef.current = hasSignature;
- setHasValidSignature(hasSignature);
-
- // 서명이 완료되었을 때 콜백 실행
- if (hasSignature && onSignatureCompleteRef.current) {
- console.log('✍️ 서명 완료 콜백 실행!');
- onSignatureCompleteRef.current();
- }
- }
- } catch (error) {
- console.error('📛 서명 모니터링 에러:', error);
- }
- }, 2000);
+ const onDocumentLoaded = () => {
+ setTimeout(checkSigned, 2000);
};
- // 문서 로드 후 모니터링 시작
- const { documentViewer } = instance.Core;
-
- const handleDocumentLoaded = () => {
- console.log('📄 문서 로드 완료, 서명 모니터링 준비');
- // 문서 로드 후 3초 뒤에 모니터링 시작 (안정성 확보)
- setTimeout(startMonitoring, 3000);
+ const onPageUpdated = () => {
+ setTimeout(checkSigned, 1000);
};
- if (documentViewer?.getDocument()) {
- // 이미 문서가 로드되어 있다면 바로 시작
- setTimeout(startMonitoring, 1000);
- } else {
- // 문서 로드 대기
- documentViewer?.addEventListener('documentLoaded', handleDocumentLoaded);
+ const onAnnotationSelected = () => {
+ setTimeout(checkSigned, 500);
+ };
+
+ const onAnnotationUnselected = () => {
+ setTimeout(checkSigned, 1000);
+ };
+
+ try {
+ annotationManager.addEventListener('annotationChanged', onAnnotationChanged);
+ annotationManager.addEventListener('annotationSelected', onAnnotationSelected);
+ annotationManager.addEventListener('annotationUnselected', onAnnotationUnselected);
+ documentViewer.addEventListener('documentLoaded', onDocumentLoaded);
+ documentViewer.addEventListener('pageNumberUpdated', onPageUpdated);
+
+ } catch (error) {
+ console.error('이벤트 리스너 등록 실패:', error);
}
- // 클리너 함수
+ if (documentViewer.getDocument()) {
+ setTimeout(checkSigned, 1000);
+ }
+
+ const pollInterval = setInterval(() => {
+ checkSigned();
+ }, 5000);
+
return () => {
- console.log('🧹 서명 모니터링 정리');
- if (checkIntervalRef.current) {
- clearInterval(checkIntervalRef.current);
- checkIntervalRef.current = null;
+ try {
+ annotationManager.removeEventListener('annotationChanged', onAnnotationChanged);
+ annotationManager.removeEventListener('annotationSelected', onAnnotationSelected);
+ annotationManager.removeEventListener('annotationUnselected', onAnnotationUnselected);
+ documentViewer.removeEventListener('documentLoaded', onDocumentLoaded);
+ documentViewer.removeEventListener('pageNumberUpdated', onPageUpdated);
+ clearInterval(pollInterval);
+ } catch (error) {
+ console.error('클린업 중 오류:', error);
}
- documentViewer?.removeEventListener('documentLoaded', handleDocumentLoaded);
};
- }, [instance]); // onSignatureComplete 제거하여 무한 렌더링 방지
+ }, [instance, hasValidSignature]);
- // 수동 서명 확인 함수
- const manualCheckSignature = useCallback(async () => {
- console.log('🔍 수동 서명 확인 요청');
- const hasSignature = await checkSignatureFields();
- setHasValidSignature(hasSignature);
- lastSignatureStateRef.current = hasSignature;
- return hasSignature;
- }, [checkSignatureFields]);
-
- return {
- hasValidSignature,
- checkSignature: manualCheckSignature
- };
+ return { hasValidSignature };
}
export function BasicContractSignViewer({
@@ -524,8 +495,10 @@ export function BasicContractSignViewer({
onSign,
instance,
setInstance,
- onSurveyComplete, // 🔥 추가
- onSignatureComplete, // 🔥 추가
+ onSurveyComplete,
+ onSignatureComplete,
+ onGtcCommentStatusChange,
+ mode = 'vendor', // 기본값 vendor
t = (key: string) => key,
}: BasicContractSignViewerProps) {
@@ -535,7 +508,9 @@ export function BasicContractSignViewer({
const [isInitialLoaded, setIsInitialLoaded] = useState<boolean>(false);
const [surveyTemplate, setSurveyTemplate] = useState<SurveyTemplateWithQuestions | null>(null);
const [surveyLoading, setSurveyLoading] = useState<boolean>(false);
- const [isSubmitting, setIsSubmitting] = useState(false); // 제출 상태 추가
+ const [gtcCommentStatus, setGtcCommentStatus] = useState<{ hasComments: boolean; commentCount: number }>({ hasComments: false, commentCount: 0 });
+
+ console.log(surveyTemplate, "surveyTemplate")
const conditionalHandler = useConditionalSurvey(surveyTemplate);
@@ -546,13 +521,15 @@ export function BasicContractSignViewer({
const [showDialog, setShowDialog] = useState(isOpen);
const webViewerInstance = useRef<WebViewerInstance | null>(null);
- const { signatureFields, isProcessing: isAutoSignProcessing, hasSignatureFields, error: autoSignError } = useAutoSignatureFields(webViewerInstance.current || instance);
-
- // 🔥 서명 감지 훅 사용
+ // mode 전달
+ const { signatureFields, isProcessing: isAutoSignProcessing, hasSignatureFields, error: autoSignError } = useAutoSignatureFields(webViewerInstance.current || instance, mode);
+
const { hasValidSignature } = useSignatureDetection(webViewerInstance.current || instance, onSignatureComplete);
- const isComplianceTemplate = templateName.includes('준법');
- const isNDATemplate = templateName.includes('비밀유지') || templateName.includes('NDA');
+ // 구매자 모드일 때는 템플릿 관련 로직 비활성화
+ const isComplianceTemplate = mode === 'buyer' ? false : templateName.includes('준법');
+ const isNDATemplate = mode === 'buyer' ? false : (templateName.includes('비밀유지') || templateName.includes('NDA'));
+ const isGTCTemplate = mode === 'buyer' ? false : templateName.includes('GTC');
const allFiles: FileInfo[] = React.useMemo(() => {
const files: FileInfo[] = [];
@@ -565,6 +542,11 @@ export function BasicContractSignViewer({
});
}
+ // 구매자 모드일 때는 추가 파일, 설문조사, 조항 검토 탭 제외
+ if (mode === 'buyer') {
+ return files; // 메인 계약서 파일만 반환
+ }
+
const normalizedAttachments: FileInfo[] = (additionalFiles || [])
.map((f: any, idx: number) => ({
path: f.path ?? f.filePath ?? "",
@@ -583,8 +565,16 @@ export function BasicContractSignViewer({
});
}
+ if (isGTCTemplate) {
+ files.push({
+ path: "",
+ name: "조항 검토",
+ type: "clauses",
+ });
+ }
+
return files;
- }, [filePath, additionalFiles, templateName, isComplianceTemplate]);
+ }, [filePath, additionalFiles, templateName, isComplianceTemplate, isGTCTemplate, mode]);
const cleanupHtmlStyle = () => {
const elements = document.querySelectorAll('.Document_container');
@@ -623,7 +613,8 @@ export function BasicContractSignViewer({
useEffect(() => {
setShowDialog(isOpen);
- if (isOpen && isComplianceTemplate && !surveyTemplate) {
+ // 구매자 모드가 아닐 때만 설문조사 템플릿 로드
+ if (isOpen && isComplianceTemplate && !surveyTemplate && mode !== 'buyer') {
loadSurveyTemplate();
}
@@ -631,7 +622,7 @@ export function BasicContractSignViewer({
setIsInitialLoaded(false);
currentDocumentPath.current = "";
}
- }, [isOpen, isComplianceTemplate]);
+ }, [isOpen, isComplianceTemplate, mode]);
useEffect(() => {
if (!filePath) return;
@@ -709,7 +700,9 @@ export function BasicContractSignViewer({
setInstance(newInstance);
setFileLoading(false);
- const { documentViewer } = newInstance.Core;
+ const { documentViewer, annotationManager, Annotations } = newInstance.Core;
+
+ const { WidgetFlags } = Annotations;
const FitMode = newInstance.UI.FitMode;
const handleDocumentLoaded = () => {
@@ -730,6 +723,30 @@ export function BasicContractSignViewer({
documentViewer.addEventListener('documentLoaded', handleDocumentLoaded);
+ // 구매자 모드가 아닐 때만 자동 서명 적용
+ if (mode !== 'buyer') {
+ annotationManager.addEventListener('annotationChanged', async (annotList, type) => {
+ for (const annot of annotList) {
+ const { fieldName, X, Y, Width, Height, PageNumber } = annot;
+
+ if (type === "add" && annot.Subject === "Widget") {
+ const signatureImage = await getVendorSignatureFile()
+
+ const stamp = new Annotations.StampAnnotation();
+ stamp.PageNumber = PageNumber;
+ stamp.X = X;
+ stamp.Y = Y;
+ stamp.Width = Width;
+ stamp.Height = Height;
+
+ await stamp.setImageData(signatureImage.data.dataUrl);
+ annot.sign(stamp);
+ annot.setFieldFlag(WidgetFlags.READ_ONLY, true);
+ }
+ }
+ });
+ }
+
newInstance.UI.setMinZoomLevel('25%');
newInstance.UI.setMaxZoomLevel('400%');
@@ -789,7 +806,7 @@ export function BasicContractSignViewer({
isCancelled.current = true;
cleanupWebViewer();
};
- }, [setInstance]);
+ }, [setInstance, mode]);
const getExtFromPath = (p: string) => {
const m = p.toLowerCase().match(/\.([a-z0-9]+)(?:\?.*)?$/);
@@ -873,17 +890,21 @@ export function BasicContractSignViewer({
const handleTabChange = async (newTab: string) => {
setActiveTab(newTab);
- if (newTab === "survey") return;
+ if (newTab === "survey" || newTab === "clauses") return;
const currentInstance = webViewerInstance.current || instance;
if (!currentInstance || fileLoading) return;
+ if (newTab === 'survey' && !surveyTemplate && !surveyLoading && mode !== 'buyer') {
+ loadSurveyTemplate();
+ }
+
let targetFile: FileInfo | undefined;
if (newTab === "main") {
targetFile = allFiles.find(f => f.type === "main");
} else if (newTab.startsWith("file-")) {
const fileIndex = parseInt(newTab.replace("file-", ""), 10);
- targetFile = allFiles.filter(f => f.type !== 'survey')[fileIndex];
+ targetFile = allFiles.filter(f => f.type !== 'survey' && f.type !== 'clauses')[fileIndex];
}
if (!targetFile?.path) {
@@ -909,7 +930,7 @@ export function BasicContractSignViewer({
documentViewer.updateView();
window.dispatchEvent(new Event("resize"));
} catch (e) {
- console.warn("탭 변경 후 레이아웃 새로고침 스킵:", e);
+ console.warn("탭 변경 후 레이아웃 새고침 스킵:", e);
}
});
} catch (e) {
@@ -965,22 +986,34 @@ export function BasicContractSignViewer({
downloadType: "pdf",
});
- if (isComplianceTemplate && !surveyData.completed) {
- toast.error("준법 설문조사를 먼저 완료해주세요.");
- setActiveTab('survey');
- return;
+ // 구매자 모드일 때는 설문조사와 GTC 검증 건너뛰기
+ if (mode !== 'buyer') {
+ if (isComplianceTemplate && !surveyData.completed) {
+ toast.error("준법 설문조사를 먼저 완료해주세요.");
+ setActiveTab('survey');
+ return;
+ }
+
+ if (isGTCTemplate && gtcCommentStatus.hasComments) {
+ toast.error("GTC 조항에 코멘트가 있어 서명할 수 없습니다.");
+ toast.info("모든 코멘트를 삭제하거나 협의를 완료한 후 서명해주세요.");
+ setActiveTab('clauses');
+ return;
+ }
}
if (onSign) {
await onSign(documentData, { formData, surveyData, signatureFields });
} else {
- toast.success("계약서가 성공적으로 서명되었습니다.");
+ const actionText = mode === 'buyer' ? "최종승인" : "서명";
+ toast.success(`계약서가 성공적으로 ${actionText}되었습니다.`);
}
handleClose();
} catch (error) {
- console.error("📛 서명 저장 실패:", error);
- toast.error("서명을 저장하는데 실패했습니다.");
+ console.error(`📛 ${mode === 'buyer' ? '최종승인' : '서명'} 저장 실패:`, error);
+ const actionText = mode === 'buyer' ? "최종승인" : "서명";
+ toast.error(`${actionText}을 저장하는데 실패했습니다.`);
}
};
@@ -992,842 +1025,7 @@ export function BasicContractSignViewer({
}
};
- // 개선된 SurveyComponent
- const SurveyComponent = () => {
- const {
- control,
- watch,
- setValue,
- getValues,
- formState: { errors },
- trigger,
- } = useForm<SurveyFormData>({
- defaultValues: {},
- mode: 'onChange'
- });
-
- const watchedValues = watch();
- const [uploadedFiles, setUploadedFiles] = useState<Record<number, File[]>>({});
-
- // 📊 실시간 진행 상태 계산
- const progressStatus = useMemo(() => {
- if (!conditionalHandler || !surveyTemplate) {
- return {
- visibleQuestions: [],
- totalRequired: 0,
- completedRequired: 0,
- completedQuestionIds: [],
- incompleteQuestionIds: [],
- progressPercentage: 0,
- debugInfo: {}
- };
- }
-
- console.log('🔄 실시간 프로그레스 재계산 중...');
- console.log('📝 원본 watchedValues:', watchedValues);
-
- // 현재 답변을 조건부 핸들러가 인식할 수 있는 형태로 변환
- const convertedAnswers: Record<number, any> = {};
- Object.entries(watchedValues).forEach(([questionId, value]) => {
- const id = parseInt(questionId);
- const convertedValue = {
- questionId: id,
- answerValue: value?.answerValue || '',
- detailText: value?.detailText || '',
- otherText: value?.otherText || '',
- files: value?.files || []
- };
-
- convertedAnswers[id] = convertedValue;
-
- // 각 질문의 변환 과정 로그
- if (value?.answerValue) {
- console.log(`📝 질문 ${id} 변환:`, {
- 원본: value,
- 변환후: convertedValue
- });
- }
- });
-
- console.log('📝 변환된 답변들 최종:', convertedAnswers);
-
- const result = conditionalHandler.getSimpleProgressStatus(convertedAnswers);
-
- console.log('📊 실시간 진행 상태 최종 결과:', {
- 전체표시질문: result.visibleQuestions.length,
- 필수질문수: result.totalRequired,
- 완료된필수질문: result.completedRequired,
- 진행률: result.progressPercentage,
- 완료된질문들: result.completedQuestionIds,
- 미완료질문들: result.incompleteQuestionIds,
- 기본질문: result.visibleQuestions.filter(q => !q.parentQuestionId).length,
- 조건부질문: result.visibleQuestions.filter(q => q.parentQuestionId).length,
- 완료된기본질문: result.completedQuestionIds.filter(id => !result.visibleQuestions.find(q => q.id === id)?.parentQuestionId).length,
- 완료된조건부질문: result.completedQuestionIds.filter(id => !!result.visibleQuestions.find(q => q.id === id)?.parentQuestionId).length
- });
-
- // 🚨 조건부 질문들의 답변 상태 특별 점검
- const conditionalQuestions = result.visibleQuestions.filter(q => q.parentQuestionId);
- if (conditionalQuestions.length > 0) {
- console.log('🚨 조건부 질문들 답변 상태 점검:', conditionalQuestions.map(q => ({
- id: q.id,
- questionNumber: q.questionNumber,
- isRequired: q.isRequired,
- parentId: q.parentQuestionId,
- watchedValue: watchedValues[q.id],
- convertedAnswer: convertedAnswers[q.id],
- hasWatchedAnswer: !!watchedValues[q.id]?.answerValue,
- hasConvertedAnswer: !!convertedAnswers[q.id]?.answerValue,
- isInRequiredList: result.totalRequired,
- isCompleted: result.completedQuestionIds.includes(q.id)
- })));
- }
-
- return result;
- }, [conditionalHandler, watchedValues, surveyTemplate]);
-
- // 🎯 동적 상태 정보
- const visibleQuestions = progressStatus.visibleQuestions;
- const totalVisibleQuestions = visibleQuestions.length;
- const baseQuestionCount = surveyTemplate?.questions.length || 0;
- const conditionalQuestionCount = totalVisibleQuestions - baseQuestionCount;
- const hasConditionalQuestions = conditionalQuestionCount > 0;
-
- // ✅ 완료 가능 여부
- const canComplete = progressStatus.totalRequired > 0 &&
- progressStatus.completedRequired === progressStatus.totalRequired;
-
- if (surveyLoading) {
- return (
- <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>
- );
- }
-
- // 🚨 템플릿이 로드되면 모든 질문들의 isRequired 속성 확인
- React.useEffect(() => {
- if (surveyTemplate && surveyTemplate.questions) {
- console.log('🚨 설문 템플릿의 모든 질문들 isRequired 속성 확인:', surveyTemplate.questions.map(q => ({
- id: q.id,
- questionNumber: q.questionNumber,
- questionText: q.questionText?.substring(0, 30) + '...',
- isRequired: q.isRequired,
- parentQuestionId: q.parentQuestionId,
- conditionalValue: q.conditionalValue,
- isConditional: !!q.parentQuestionId
- })));
-
- const allQuestions = surveyTemplate.questions.length;
- const requiredQuestions = surveyTemplate.questions.filter(q => q.isRequired).length;
- const conditionalQuestions = surveyTemplate.questions.filter(q => q.parentQuestionId).length;
- const requiredConditionalQuestions = surveyTemplate.questions.filter(q => q.parentQuestionId && q.isRequired).length;
-
- console.log('📊 템플릿 질문 통계:', {
- 전체질문수: allQuestions,
- 전체필수질문수: requiredQuestions,
- 조건부질문수: conditionalQuestions,
- 필수조건부질문수: requiredConditionalQuestions,
- 기본질문수: allQuestions - conditionalQuestions,
- 필수기본질문수: requiredQuestions - requiredConditionalQuestions
- });
-
- // 🚨 만약 조건부 질문들이 필수가 아니라면 경고
- if (conditionalQuestions > 0 && requiredConditionalQuestions === 0) {
- console.warn('⚠️ 경고: 조건부 질문들이 모두 필수가 아닙니다! 데이터베이스 확인 필요');
- console.warn('조건부 질문들:', surveyTemplate.questions.filter(q => q.parentQuestionId));
- }
- }
- }, [surveyTemplate]);
-
- const handleFileUpload = useCallback((questionId: number, files: FileList | null) => {
- if (!files) return;
-
- const fileArray = Array.from(files);
- setUploadedFiles(prev => ({
- ...prev,
- [questionId]: fileArray
- }));
-
- setValue(`${questionId}.files`, fileArray);
- }, [setValue]);
-
- const handleAnswerChange = useCallback((questionId: number, field: string, value: any) => {
- console.log(`📝 답변 변경: 질문 ${questionId}, 필드 ${field}, 값:`, value);
-
- // 해당 질문이 조건부 질문인지 확인
- const question = visibleQuestions.find(q => q.id === questionId);
- if (question) {
- console.log(`📋 질문 ${questionId} 상세 정보:`, {
- id: question.id,
- questionNumber: question.questionNumber,
- isRequired: question.isRequired,
- parentQuestionId: question.parentQuestionId,
- conditionalValue: question.conditionalValue,
- isConditional: !!question.parentQuestionId
- });
- }
-
- setValue(`${questionId}.${field}`, value);
-
- // setValue 후 현재 값 확인
- setTimeout(() => {
- const currentFormValues = getValues();
- console.log(`✅ setValue 후 확인 - 질문 ${questionId}:`, {
- 설정한값: value,
- 저장된전체값: currentFormValues[questionId],
- 전체폼값: currentFormValues
- });
- }, 0);
-
- // 부모 질문의 답변이 변경되면 조건부 자식 질문들 처리
- if (field === 'answerValue' && conditionalHandler) {
- const currentValues = getValues();
- const convertedAnswers: Record<number, any> = {};
-
- Object.entries(currentValues).forEach(([qId, qValue]) => {
- const id = parseInt(qId);
- convertedAnswers[id] = {
- questionId: id,
- answerValue: qValue?.answerValue || '',
- detailText: qValue?.detailText || '',
- otherText: qValue?.otherText || '',
- files: qValue?.files || []
- };
- });
-
- // 새로운 답변 반영
- convertedAnswers[questionId] = {
- ...convertedAnswers[questionId],
- questionId,
- [field]: value
- };
-
- console.log(`🔄 질문 ${questionId}의 답변 변경으로 인한 조건부 질문 처리...`);
- console.log(`🔄 변경 후 전체 답변:`, convertedAnswers);
-
- // 영향받는 자식 질문들의 답변 초기화
- const clearedAnswers = conditionalHandler.clearAffectedChildAnswers(questionId, value, convertedAnswers);
-
- console.log(`🧹 정리된 답변들:`, clearedAnswers);
-
- // 삭제된 답변들을 폼에서도 제거
- Object.keys(convertedAnswers).forEach(qId => {
- const id = parseInt(qId);
- if (id !== questionId && !clearedAnswers[id]) {
- console.log(`🗑️ 질문 ${id} 답변 초기화`);
- setValue(`${id}`, {
- answerValue: '',
- detailText: '',
- otherText: '',
- files: []
- });
-
- // 업로드된 파일도 초기화
- setUploadedFiles(prev => {
- const updated = { ...prev };
- delete updated[id];
- return updated;
- });
- }
- });
- }
- }, [setValue, getValues, conditionalHandler, visibleQuestions]);
-
- // 🔥 설문조사 완료 핸들러 수정
- const handleSurveyComplete = useCallback(async () => {
- console.log('🎯 설문조사 완료 시도');
-
- // 이미 제출 중이면 중복 실행 방지
- if (isSubmitting) {
- console.log('⚠️ 이미 제출 중...');
- return;
- }
-
- setIsSubmitting(true);
-
- try {
- const currentValues = getValues();
- console.log('📝 현재 폼 값들:', currentValues);
-
- // 폼 검증
- const isValid = await trigger();
- console.log('🔍 폼 검증 결과:', isValid);
-
- // 진행 상태 최종 확인
- console.log('📊 최종 진행 상태:', {
- totalRequired: progressStatus.totalRequired,
- completedRequired: progressStatus.completedRequired,
- canComplete,
- 완료된질문들: progressStatus.completedQuestionIds,
- 미완료질문들: progressStatus.incompleteQuestionIds
- });
-
- if (!canComplete) {
- let errorMessage = '모든 필수 항목을 완료해주세요.';
- let errorDescription = `${progressStatus.completedRequired}/${progressStatus.totalRequired} 완료됨`;
-
- // 구체적인 미완료 이유 표시
- if (progressStatus.incompleteQuestionIds.length > 0) {
- const incompleteReasons = progressStatus.incompleteQuestionIds.map(id => {
- const debug = progressStatus.debugInfo?.[id];
- const question = visibleQuestions.find(q => q.id === id);
- return `• Q${question?.questionNumber}: ${question?.questionText?.substring(0, 40)}...\n → ${debug?.incompleteReason || '답변 필요'}`;
- }).slice(0, 3);
-
- errorDescription = incompleteReasons.join('\n\n');
-
- if (progressStatus.incompleteQuestionIds.length > 3) {
- errorDescription += `\n\n... 외 ${progressStatus.incompleteQuestionIds.length - 3}개 항목`;
- }
- }
-
- toast.error(errorMessage, {
- description: errorDescription,
- icon: <AlertTriangle className="h-5 w-5 text-red-500" />,
- duration: 12000
- });
-
- // 첫 번째 미완료 질문으로 스크롤
- if (progressStatus.incompleteQuestionIds.length > 0) {
- const firstIncompleteId = progressStatus.incompleteQuestionIds[0];
- const element = document.getElementById(`question-${firstIncompleteId}`);
- if (element) {
- element.scrollIntoView({ behavior: 'smooth', block: 'center' });
- }
- }
-
- return;
- }
-
- // 필수 데이터 확인
- if (!contractId || !surveyTemplate?.id) {
- toast.error('계약서 정보 또는 설문 템플릿 정보가 없습니다.');
- return;
- }
-
- // 서버 액션에 전달할 데이터 준비
- const surveyAnswers: SurveyAnswerData[] = Object.entries(currentValues)
- .map(([questionId, value]) => ({
- questionId: parseInt(questionId),
- answerValue: value?.answerValue || '',
- detailText: value?.detailText || '',
- otherText: value?.otherText || '',
- files: value?.files || []
- }))
- .filter(answer =>
- // 빈 답변 필터링 (하지만 필수 질문의 답변이 완료되었음을 이미 확인했음)
- answer.answerValue || answer.detailText || (answer.files && answer.files.length > 0)
- );
-
- const requestData: CompleteSurveyRequest = {
- contractId: contractId,
- templateId: surveyTemplate.id,
- answers: surveyAnswers,
- progressStatus: progressStatus // 디버깅용 추가 정보
- };
-
- console.log('📤 서버로 전송할 데이터:', {
- contractId: requestData.contractId,
- templateId: requestData.templateId,
- answersCount: requestData.answers.length,
- answers: requestData.answers.map(a => ({
- questionId: a.questionId,
- hasAnswer: !!a.answerValue,
- hasDetail: !!a.detailText,
- hasOther: !!a.otherText,
- filesCount: a.files?.length || 0
- }))
- });
-
- // 제출 중 토스트 표시
- const submitToast = toast.loading('설문조사를 저장하는 중...', {
- description: '잠시만 기다려주세요.',
- duration: Infinity
- });
-
- // 서버 액션 호출
- const result = await completeSurvey(requestData);
-
- // 로딩 토스트 제거
- toast.dismiss(submitToast);
-
- if (result.success) {
- // 클라이언트 상태 업데이트 (기존 로직 유지)
- setSurveyData({
- completed: true,
- answers: surveyAnswers,
- timestamp: new Date().toISOString(),
- progressStatus: progressStatus,
- totalQuestions: totalVisibleQuestions,
- conditionalQuestions: conditionalQuestionCount,
- responseId: result.data?.responseId // 서버에서 반환된 응답 ID 저장
- });
-
- // 🔥 부모 컴포넌트에 설문조사 완료 알림
- if (onSurveyComplete) {
- onSurveyComplete();
- }
-
- toast.success("🎉 설문조사가 완료되었습니다!", {
- description: `총 ${progressStatus.totalRequired}개 필수 질문 완료${hasConditionalQuestions ? ` (조건부 ${conditionalQuestionCount}개 포함)` : ''}`,
- icon: <CheckCircle2 className="h-5 w-5 text-green-500" />,
- duration: 5000
- });
-
- console.log('✅ 설문조사 완료:', {
- totalAnswered: surveyAnswers.length,
- totalRequired: progressStatus.totalRequired,
- conditionalQuestions: conditionalQuestionCount,
- responseId: result.data?.responseId
- });
-
- // 자동으로 메인 탭으로 이동 (선택사항)
- setTimeout(() => {
- setActiveTab('main');
- }, 2000);
-
- } else {
- // 서버 에러 처리
- console.error('❌ 서버 응답 에러:', result.message);
- toast.error('설문조사 저장 실패', {
- description: result.message || '서버에서 오류가 발생했습니다.',
- icon: <AlertTriangle className="h-5 w-5 text-red-500" />,
- duration: 8000
- });
- }
-
- } catch (error) {
- console.error('❌ 설문조사 저장 중 예외 발생:', error);
-
- let errorMessage = '설문조사 저장에 실패했습니다.';
- let errorDescription = '네트워크 연결을 확인하고 다시 시도해주세요.';
-
- if (error instanceof Error) {
- errorDescription = error.message;
- }
-
- toast.error(errorMessage, {
- description: errorDescription,
- icon: <AlertTriangle className="h-5 w-5 text-red-500" />,
- duration: 10000
- });
- } finally {
- setIsSubmitting(false);
- }
- }, [
- getValues,
- trigger,
- progressStatus,
- visibleQuestions,
- canComplete,
- contractId,
- surveyTemplate?.id,
- totalVisibleQuestions,
- conditionalQuestionCount,
- hasConditionalQuestions,
- isSubmitting,
- setActiveTab,
- onSurveyComplete // 🔥 추가
- ]);
-
- // OTHER 텍스트 입력 컴포넌트
- const OtherTextInput = ({ questionId, fieldName }: { questionId: number; fieldName: string }) => {
- const answerValue = useWatch({
- control,
- name: `${fieldName}.answerValue`
- });
-
- if (answerValue !== 'OTHER') return null;
-
- return (
- <Controller
- name={`${fieldName}.otherText`}
- control={control}
- render={({ field }) => (
- <Input
- {...field}
- placeholder="기타 내용을 입력해주세요"
- className="mt-2"
- />
- )}
- />
- );
- };
-
- 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}
- {conditionalHandler && (
- <Badge variant="outline" className="ml-2 text-xs">
- 조건부 질문 지원
- </Badge>
- )}
- </div>
- <div className="text-sm text-gray-600">
- {progressStatus.completedRequired}/{progressStatus.totalRequired} 완료
- </div>
- </CardTitle>
-
- <CardDescription>
- {surveyTemplate.description}
-
- {/* 🎯 동적 질문 수 표시 */}
- <div className="mt-2 space-y-1">
- <div className="flex items-center text-sm">
- <span className="text-gray-600">
- 📋 총 {totalVisibleQuestions}개 질문
- {hasConditionalQuestions && (
- <span className="text-blue-600 ml-1">
- (기본 {baseQuestionCount}개 + 조건부 {conditionalQuestionCount}개)
- </span>
- )}
- </span>
- </div>
-
- {hasConditionalQuestions && (
- <div className="text-blue-600 text-sm">
- ⚡ 답변에 따라 {conditionalQuestionCount}개 추가 질문이 나타났습니다
- </div>
- )}
- </div>
- </CardDescription>
-
- {/* 📊 동적 프로그레스 바 */}
- <div className="space-y-2">
- <div className="flex justify-between text-xs text-gray-600">
- <span>필수 질문 진행률</span>
- <span>
- {Math.round(progressStatus.progressPercentage)}%
- {hasConditionalQuestions && (
- <span className="ml-1 text-blue-600">
- (조건부 포함)
- </span>
- )}
- </span>
- </div>
- <div className="w-full bg-gray-200 rounded-full h-2">
- <div
- className="bg-gradient-to-r from-blue-500 to-blue-600 h-2 rounded-full transition-all duration-500 ease-out"
- style={{ width: `${progressStatus.progressPercentage}%` }}
- />
- </div>
-
- {/* 세부 진행 상황 */}
- {progressStatus.totalRequired > 0 && (
- <div className="text-xs text-gray-500 flex justify-between">
- <span>완료: {progressStatus.completedRequired}개</span>
- <span>남은 필수: {progressStatus.totalRequired - progressStatus.completedRequired}개</span>
- </div>
- )}
- </div>
- </CardHeader>
-
- <CardContent className="flex-1 min-h-0 overflow-y-auto">
- <form onSubmit={(e) => e.preventDefault()} 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">
- 본 설문조사는 준법 의무 확인을 위한 필수 절차입니다. 모든 항목을 정확히 작성해주세요.
- {conditionalHandler && (
- <span className="block mt-1">
- ⚡ 답변에 따라 추가 질문이 나타날 수 있으며, 이 경우 모든 추가 질문도 완료해야 합니다.
- </span>
- )}
- </p>
- </div>
- </div>
- </div>
-
- <div className="space-y-4">
- {visibleQuestions.map((question: any) => {
- const fieldName = `${question.id}`;
- const isComplete = progressStatus.completedQuestionIds.includes(question.id);
- const isConditional = !!question.parentQuestionId;
-
- return (
- <div
- key={question.id}
- id={`question-${question.id}`}
- className={`mb-6 p-4 border rounded-lg transition-colors duration-200 ${isConditional
- ? 'bg-blue-50 border-blue-200'
- : 'bg-gray-50 border-gray-200'
- } ${!isComplete && question.isRequired ? 'ring-2 ring-red-100' : ''}`}
- >
- <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 flex-wrap gap-2">
- <span className="px-2 py-1 bg-blue-100 text-blue-700 rounded text-xs">
- Q{question.questionNumber}
- </span>
-
- {isConditional && (
- <span className="px-2 py-1 bg-amber-100 text-amber-700 rounded text-xs">
- ⚡ 조건부 질문
- </span>
- )}
-
- {question.questionType === 'FILE' && (
- <span className="px-2 py-1 bg-green-100 text-green-700 rounded text-xs">
- 📎 파일 업로드
- </span>
- )}
-
- <div className="w-full mt-1">
- {question.questionText}
- {question.isRequired && <span className="text-red-500 ml-1">*</span>}
- </div>
- </Label>
- </div>
-
- {isComplete && (
- <CheckCircle2 className="h-5 w-5 text-green-500 ml-2 flex-shrink-0" />
- )}
- </div>
-
- {/* 질문 타입별 렌더링 (기존 코드와 동일) */}
- {/* RADIO 타입 */}
- {question.questionType === 'RADIO' && (
- <Controller
- name={`${fieldName}.answerValue`}
- control={control}
- rules={{ required: question.isRequired ? '필수 항목입니다.' : false }}
- render={({ field }) => (
- <RadioGroup
- value={field.value || ''}
- onValueChange={(value) => {
- field.onChange(value);
- handleAnswerChange(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>
- )}
- />
- )}
-
- {/* DROPDOWN 타입 */}
- {question.questionType === 'DROPDOWN' && (
- <div className="space-y-2">
- <Controller
- name={`${fieldName}.answerValue`}
- control={control}
- rules={{ required: question.isRequired ? '필수 항목입니다.' : false }}
- render={({ field }) => (
- <Select
- value={field.value || ''}
- onValueChange={(value) => {
- field.onChange(value);
- handleAnswerChange(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>
- )}
- />
-
- <OtherTextInput questionId={question.id} fieldName={fieldName} />
- </div>
- )}
-
- {/* TEXTAREA 타입 */}
- {question.questionType === 'TEXTAREA' && (
- <Controller
- name={`${fieldName}.detailText`}
- control={control}
- rules={{ required: question.isRequired ? '필수 항목입니다.' : false }}
- render={({ field }) => (
- <Textarea
- {...field}
- placeholder="상세한 내용을 입력해주세요"
- rows={4}
- />
- )}
- />
- )}
-
- {/* 상세 텍스트 입력 */}
- {question.hasDetailText && (
- <div className="mt-3">
- <Label className="text-sm text-gray-700 mb-2 block">상세 내용을 기술해주세요:</Label>
- <Controller
- name={`${fieldName}.detailText`}
- control={control}
- rules={{ required: question.isRequired ? '상세 내용을 입력해주세요.' : false }}
- render={({ field }) => (
- <Textarea
- {...field}
- placeholder="상세한 내용을 입력해주세요"
- rows={3}
- className="w-full"
- />
- )}
- />
- </div>
- )}
-
- {/* 파일 업로드 */}
- {(question.hasFileUpload || question.questionType === 'FILE') && (
- <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) => handleFileUpload(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>
- )}
-
- {/* 에러 메시지 */}
- {errors[fieldName] && (
- <p className="mt-2 text-sm text-red-600 flex items-center">
- <AlertTriangle className="h-4 w-4 mr-1" />
- {errors[fieldName]?.answerValue?.message ||
- errors[fieldName]?.detailText?.message ||
- '필수 항목을 완료해주세요.'}
- </p>
- )}
- </div>
- );
- })}
- </div>
-
- {/* ✅ 향상된 완료 버튼 */}
- <div className="flex justify-end pt-6 border-t">
- <div className="flex items-center space-x-4">
- {/* 진행 상황 요약 */}
- <div className="text-sm">
- {canComplete ? (
- <div className="text-green-600 font-medium flex items-center">
- <CheckCircle2 className="h-4 w-4 mr-1" />
- 모든 필수 항목 완료됨
- {hasConditionalQuestions && (
- <span className="ml-2 text-xs text-blue-600">
- (조건부 {conditionalQuestionCount}개 포함)
- </span>
- )}
- </div>
- ) : (
- <div className="space-y-1">
- <div className="flex items-center text-gray-600">
- <AlertTriangle className="h-4 w-4 mr-1 text-red-500" />
- {progressStatus.completedRequired}/{progressStatus.totalRequired} 완료
- </div>
- {hasConditionalQuestions && (
- <div className="text-xs text-blue-600">
- 기본 + 조건부 {conditionalQuestionCount}개 포함
- </div>
- )}
- </div>
- )}
- </div>
-
- <Button
- type="button"
- onClick={handleSurveyComplete}
- disabled={!canComplete || isSubmitting}
- className={`transition-all duration-200 ${canComplete && !isSubmitting
- ? 'bg-green-600 hover:bg-green-700 shadow-lg'
- : 'bg-gray-400 cursor-not-allowed'
- }`}
- >
- {isSubmitting ? (
- <>
- <Loader2 className="h-4 w-4 mr-2 animate-spin" />
- 저장 중...
- </>
- ) : (
- <>
- <CheckCircle2 className="h-4 w-4 mr-2" />
- 설문조사 완료
- </>
- )}
- <span className="ml-1 text-xs">
- ({progressStatus.completedRequired}/{progressStatus.totalRequired})
- </span>
- </Button>
- </div>
- </div>
- </form>
- </CardContent>
- </Card>
- </div>
- );
- };
-
- // 🔥 서명 상태 표시 컴포넌트 개선
+ // 서명 상태 표시 컴포넌트
const SignatureFieldsStatus = () => {
if (!hasSignatureFields && !isAutoSignProcessing && !autoSignError && !hasValidSignature) return null;
@@ -1846,11 +1044,10 @@ export function BasicContractSignViewer({
) : 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}개 서명 필드 자동 생성됨
+ {signatureFields.length}개 서명 필드 자동 생성됨 {mode === 'buyer' ? '(왼쪽 하단)' : ''}
</Badge>
) : null}
-
- {/* 🔥 서명 완료 상태 표시 */}
+
{hasValidSignature && (
<Badge variant="outline" className="text-xs bg-green-50 text-green-700 border-green-200">
<CheckCircle2 className="h-3 w-3 mr-1" />
@@ -1865,7 +1062,8 @@ export function BasicContractSignViewer({
if (!isOpen && !onClose) {
return (
<div className="h-full w-full flex flex-col overflow-hidden">
- {allFiles.length > 1 ? (
+ {/* 구매자 모드에서는 탭 없이 단일 뷰어만 표시 */}
+ {allFiles.length > 1 && mode !== 'buyer' ? (
<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 />
@@ -1876,8 +1074,10 @@ export function BasicContractSignViewer({
tabId = 'main';
} else if (file.type === 'survey') {
tabId = 'survey';
+ } else if (file.type === 'clauses') {
+ tabId = 'clauses';
} else {
- const fileOnlyIndex = allFiles.slice(0, index).filter(f => f.type !== 'survey').length;
+ const fileOnlyIndex = allFiles.slice(0, index).filter(f => f.type !== 'survey' && f.type !== 'clauses').length;
tabId = `file-${fileOnlyIndex}`;
}
@@ -1886,6 +1086,8 @@ export function BasicContractSignViewer({
<div className="flex items-center space-x-1">
{file.type === 'survey' ? (
<ClipboardList className="h-3 w-3" />
+ ) : file.type === 'clauses' ? (
+ <BookOpen className="h-3 w-3" />
) : (
<FileText className="h-3 w-3" />
)}
@@ -1893,6 +1095,11 @@ export function BasicContractSignViewer({
{file.type === 'survey' && surveyData.completed && (
<Badge variant="secondary" className="ml-1 h-4 px-1 text-xs">완료</Badge>
)}
+ {file.type === 'clauses' && gtcCommentStatus.hasComments && (
+ <Badge variant="destructive" className="ml-1 h-4 px-1 text-xs">
+ 코멘트 {gtcCommentStatus.commentCount}
+ </Badge>
+ )}
</div>
</TabsTrigger>
);
@@ -1904,11 +1111,35 @@ export function BasicContractSignViewer({
<div
className={`absolute inset-0 p-3 ${activeTab === 'survey' ? 'block' : 'hidden'}`}
>
- <SurveyComponent />
+ {/* 분리된 SurveyComponent 사용 */}
+ <SurveyComponent
+ contractId={contractId}
+ surveyTemplate={surveyTemplate}
+ surveyLoading={surveyLoading}
+ conditionalHandler={conditionalHandler}
+ onSurveyComplete={onSurveyComplete}
+ onSurveyDataUpdate={setSurveyData}
+ onLoadSurveyTemplate={loadSurveyTemplate}
+ setActiveTab={setActiveTab}
+ />
+ </div>
+
+ <div
+ className={`absolute inset-0 p-3 ${activeTab === 'clauses' ? 'block' : 'hidden'}`}
+ >
+ {/* GTC 조항 컴포넌트 */}
+ <GtcClausesComponent
+ contractId={contractId}
+ onCommentStatusChange={(hasComments, commentCount) => {
+ setGtcCommentStatus({ hasComments, commentCount });
+ onGtcCommentStatusChange?.(hasComments, commentCount);
+ }}
+ t={t}
+ />
</div>
<div
- className={`absolute inset-0 ${activeTab !== 'survey' ? 'block' : 'hidden'}`}
+ className={`absolute inset-0 ${activeTab !== 'survey' && activeTab !== 'clauses' ? 'block' : 'hidden'}`}
>
<div className="w-full h-full overflow-auto">
<div
@@ -1962,49 +1193,84 @@ export function BasicContractSignViewer({
);
}
+ const handleGtcCommentStatusChange = React.useCallback((hasComments: boolean, commentCount: number) => {
+ setGtcCommentStatus({ hasComments, commentCount });
+ onGtcCommentStatusChange?.(hasComments, commentCount);
+ }, [onGtcCommentStatusChange]);
+
// 다이얼로그 뷰어 렌더링
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>
+ <span>{mode === 'buyer' ? '구매자 최종승인' : '기본계약서 서명'}</span>
<SignatureFieldsStatus />
</DialogTitle>
<DialogDescription>
- 계약서를 확인하고 서명을 진행해주세요.
- {isComplianceTemplate && (
+ 계약서를 확인하고 {mode === 'buyer' ? '최종승인을' : '서명을'} 진행해주세요.
+ {mode !== 'buyer' && isComplianceTemplate && (
<span className="block mt-1 text-amber-600">📋 준법 설문조사를 먼저 완료해주세요.</span>
)}
+ {mode !== 'buyer' && isGTCTemplate && (
+ <span className="block mt-1 text-blue-600">📋 GTC 조항을 검토하고 코멘트가 없는지 확인해주세요.</span>
+ )}
{hasSignatureFields && (
<span className="block mt-1 text-green-600">
- 🎯 서명 위치가 자동으로 감지되었습니다.
+ 🎯 서명 위치가 자동으로 감지되었습니다{mode === 'buyer' ? ' (왼쪽 하단)' : ''}.
</span>
)}
- {/* 🔥 서명 완료 상태 안내 */}
{hasValidSignature && (
<span className="block mt-1 text-green-600">
- ✅ 서명이 완료되었습니다.
+ ✅ {mode === 'buyer' ? '승인이' : '서명이'} 완료되었습니다.
+ </span>
+ )}
+ {mode !== 'buyer' && isGTCTemplate && gtcCommentStatus.hasComments && (
+ <span className="block mt-1 text-red-600">
+ ⚠️ GTC 조항에 {gtcCommentStatus.commentCount}개의 코멘트가 있어 서명할 수 없습니다.
</span>
)}
</DialogDescription>
</DialogHeader>
<div className="flex-1 min-h-0 overflow-hidden">
- {allFiles.length > 1 ? (
+ {/* 구매자 모드에서는 탭 없이 단일 뷰어만 표시 */}
+ {allFiles.length > 1 && mode !== 'buyer' ? (
<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}`;
+ let tabId: string;
+ if (index === 0) {
+ tabId = 'main';
+ } else if (file.type === 'survey') {
+ tabId = 'survey';
+ } else if (file.type === 'clauses') {
+ tabId = 'clauses';
+ } else {
+ const fileOnlyIndex = allFiles.slice(0, index).filter(f => f.type !== 'survey' && f.type !== 'clauses').length;
+ tabId = `file-${fileOnlyIndex}`;
+ }
+
return (
<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" />}
+ {file.type === 'survey' ? (
+ <ClipboardList className="h-3 w-3" />
+ ) : file.type === 'clauses' ? (
+ <BookOpen 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>
)}
+ {file.type === 'clauses' && gtcCommentStatus.hasComments && (
+ <Badge variant="destructive" className="ml-1 h-4 px-1 text-xs">
+ 코멘트 {gtcCommentStatus.commentCount}
+ </Badge>
+ )}
</div>
</TabsTrigger>
);
@@ -2014,10 +1280,29 @@ export function BasicContractSignViewer({
<div className="flex-1 min-h-0 overflow-hidden relative">
<div className={`absolute inset-0 p-3 ${activeTab === 'survey' ? 'block' : 'hidden'}`}>
- <SurveyComponent />
+ {/* 분리된 SurveyComponent 사용 */}
+ <SurveyComponent
+ contractId={contractId}
+ surveyTemplate={surveyTemplate}
+ surveyLoading={surveyLoading}
+ conditionalHandler={conditionalHandler}
+ onSurveyComplete={onSurveyComplete}
+ onSurveyDataUpdate={setSurveyData}
+ onLoadSurveyTemplate={loadSurveyTemplate}
+ setActiveTab={setActiveTab}
+ />
+ </div>
+
+ <div className={`absolute inset-0 p-3 ${activeTab === 'clauses' ? 'block' : 'hidden'}`}>
+ {/* GTC 조항 컴포넌트 */}
+ <GtcClausesComponent
+ contractId={contractId}
+ onCommentStatusChange={handleGtcCommentStatusChange} // 메모이제이션된 콜백 사용
+ t={t}
+ />
</div>
- <div className={`absolute inset-0 ${activeTab !== 'survey' ? 'block' : 'hidden'}`}>
+ <div className={`absolute inset-0 ${activeTab !== 'survey' && activeTab !== 'clauses' ? 'block' : 'hidden'}`}>
<div className="w-full h-full overflow-auto">
<div
ref={viewer}
@@ -2069,7 +1354,7 @@ export function BasicContractSignViewer({
<Button variant="outline" onClick={handleClose} disabled={fileLoading}>취소</Button>
<Button onClick={handleSave} disabled={fileLoading || isAutoSignProcessing}>
<FileSignature className="h-4 w-4 mr-2" />
- 서명 완료
+ {mode === 'buyer' ? '최종승인 완료' : '서명 완료'}
</Button>
</DialogFooter>
</DialogContent>