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.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>