summaryrefslogtreecommitdiff
path: root/lib/basic-contract
diff options
context:
space:
mode:
authordujinkim <dujin.kim@dtsolution.co.kr>2025-08-26 01:17:56 +0000
committerdujinkim <dujin.kim@dtsolution.co.kr>2025-08-26 01:17:56 +0000
commit12e936c0b45ffa1c8f3c02ff77961212767be9a7 (patch)
tree34f31b9a64c6d30e187c1114530c4d47b95d30a9 /lib/basic-contract
parent83f67ed333f0237b434a41d1eceef417c0d48313 (diff)
(대표님) 가입, 기본계약, 벤더
(최겸) 기술영업 아이템 관련
Diffstat (limited to 'lib/basic-contract')
-rw-r--r--lib/basic-contract/service.ts506
-rw-r--r--lib/basic-contract/vendor-table/basic-contract-sign-dialog.tsx561
-rw-r--r--lib/basic-contract/vendor-table/basic-contract-table.tsx1
-rw-r--r--lib/basic-contract/vendor-table/survey-conditional.ts860
-rw-r--r--lib/basic-contract/viewer/basic-contract-sign-viewer.tsx2975
5 files changed, 3490 insertions, 1413 deletions
diff --git a/lib/basic-contract/service.ts b/lib/basic-contract/service.ts
index 64a50d14..58463f16 100644
--- a/lib/basic-contract/service.ts
+++ b/lib/basic-contract/service.ts
@@ -11,9 +11,18 @@ import {
BasicContractTemplate,
basicContractTemplates,
basicContractView,
+ complianceQuestionOptions,
+ complianceQuestions,
+ complianceResponseAnswers,
+ complianceResponseFiles,
+ complianceResponses,
+ complianceSurveyTemplates,
vendorAttachments,
vendors,
type BasicContractTemplate as DBBasicContractTemplate,
+ type NewComplianceResponse,
+ type NewComplianceResponseAnswer,
+ type NewComplianceResponseFile
} from "@/db/schema";
import {
@@ -1172,3 +1181,500 @@ export async function getVendorAttachments(vendorId: number) {
};
}
}
+
+// 설문조사 템플릿 전체 데이터 타입
+export interface SurveyTemplateWithQuestions {
+ id: number;
+ name: string;
+ description: string | null;
+ version: string;
+ questions: SurveyQuestion[];
+}
+
+export interface SurveyQuestion {
+ id: number;
+ questionNumber: string;
+ questionText: string;
+ questionType: string;
+ isRequired: boolean;
+ hasDetailText: boolean;
+ hasFileUpload: boolean;
+ parentQuestionId: number | null;
+ conditionalValue: string | null;
+ displayOrder: number;
+ options: SurveyQuestionOption[];
+}
+
+export interface SurveyQuestionOption {
+ id: number;
+ optionValue: string;
+ optionText: string;
+ allowsOtherInput: boolean;
+ displayOrder: number;
+}
+
+/**
+ * 활성화된 첫 번째 설문조사 템플릿과 관련 데이터를 모두 가져오기
+ */
+export async function getActiveSurveyTemplate(): Promise<SurveyTemplateWithQuestions | null> {
+ try {
+ // 1. 활성화된 첫 번째 템플릿 가져오기
+ const template = await db
+ .select()
+ .from(complianceSurveyTemplates)
+ .where(eq(complianceSurveyTemplates.isActive, true))
+ .orderBy(complianceSurveyTemplates.id)
+ .limit(1);
+
+ if (!template || template.length === 0) {
+ console.log('활성화된 설문조사 템플릿이 없습니다.');
+ return null;
+ }
+
+ const templateData = template[0];
+
+ // 2. 해당 템플릿의 모든 질문 가져오기 (displayOrder 순)
+ const questions = await db
+ .select()
+ .from(complianceQuestions)
+ .where(eq(complianceQuestions.templateId, templateData.id))
+ .orderBy(asc(complianceQuestions.displayOrder));
+
+ // 3. 각 질문의 옵션들 가져오기
+ const questionIds = questions.map(q => q.id);
+ const allOptions = questionIds.length > 0
+ ? await db
+ .select()
+ .from(complianceQuestionOptions)
+ .where(inArray(complianceQuestionOptions.questionId, questionIds))
+ .orderBy(
+ complianceQuestionOptions.questionId,
+ asc(complianceQuestionOptions.displayOrder)
+ )
+ : [];
+
+
+ // 4. 질문별로 옵션들 그룹화
+ const optionsByQuestionId = allOptions.reduce((acc, option) => {
+ if (!acc[option.questionId]) {
+ acc[option.questionId] = [];
+ }
+ acc[option.questionId].push({
+ id: option.id,
+ optionValue: option.optionValue,
+ optionText: option.optionText,
+ allowsOtherInput: option.allowsOtherInput,
+ displayOrder: option.displayOrder,
+ });
+ return acc;
+ }, {} as Record<number, SurveyQuestionOption[]>);
+
+ // 5. 최종 데이터 구성
+ const questionsWithOptions: SurveyQuestion[] = questions.map(question => ({
+ id: question.id,
+ questionNumber: question.questionNumber,
+ questionText: question.questionText,
+ questionType: question.questionType,
+ isRequired: question.isRequired,
+ hasDetailText: question.hasDetailText,
+ hasFileUpload: question.hasFileUpload,
+ parentQuestionId: question.parentQuestionId,
+ conditionalValue: question.conditionalValue,
+ displayOrder: question.displayOrder,
+ options: optionsByQuestionId[question.id] || [],
+ }));
+
+ return {
+ id: templateData.id,
+ name: templateData.name,
+ description: templateData.description,
+ version: templateData.version,
+ questions: questionsWithOptions,
+ };
+
+ } catch (error) {
+ console.error('설문조사 템플릿 로드 실패:', error);
+ return null;
+ }
+}
+
+/**
+ * 특정 템플릿 ID로 설문조사 템플릿 가져오기
+ */
+export async function getSurveyTemplateById(templateId: number): Promise<SurveyTemplateWithQuestions | null> {
+ try {
+ const template = await db
+ .select()
+ .from(complianceSurveyTemplates)
+ .where(eq(complianceSurveyTemplates.id, templateId))
+ .limit(1);
+
+ if (!template || template.length === 0) {
+ return null;
+ }
+
+ const templateData = template[0];
+
+ const questions = await db
+ .select()
+ .from(complianceQuestions)
+ .where(eq(complianceQuestions.templateId, templateId))
+ .orderBy(asc(complianceQuestions.displayOrder));
+
+ const questionIds = questions.map(q => q.id);
+ const allOptions = questionIds.length > 0
+ ? await db
+ .select()
+ .from(complianceQuestionOptions)
+ .where(
+ complianceQuestionOptions.questionId.in ?
+ complianceQuestionOptions.questionId.in(questionIds) :
+ eq(complianceQuestionOptions.questionId, questionIds[0])
+ )
+ .orderBy(
+ complianceQuestionOptions.questionId,
+ asc(complianceQuestionOptions.displayOrder)
+ )
+ : [];
+
+ const optionsByQuestionId = allOptions.reduce((acc, option) => {
+ if (!acc[option.questionId]) {
+ acc[option.questionId] = [];
+ }
+ acc[option.questionId].push({
+ id: option.id,
+ optionValue: option.optionValue,
+ optionText: option.optionText,
+ allowsOtherInput: option.allowsOtherInput,
+ displayOrder: option.displayOrder,
+ });
+ return acc;
+ }, {} as Record<number, SurveyQuestionOption[]>);
+
+ const questionsWithOptions: SurveyQuestion[] = questions.map(question => ({
+ id: question.id,
+ questionNumber: question.questionNumber,
+ questionText: question.questionText,
+ questionType: question.questionType,
+ isRequired: question.isRequired,
+ hasDetailText: question.hasDetailText,
+ hasFileUpload: question.hasFileUpload,
+ parentQuestionId: question.parentQuestionId,
+ conditionalValue: question.conditionalValue,
+ displayOrder: question.displayOrder,
+ options: optionsByQuestionId[question.id] || [],
+ }));
+
+ return {
+ id: templateData.id,
+ name: templateData.name,
+ description: templateData.description,
+ version: templateData.version,
+ questions: questionsWithOptions,
+ };
+
+ } catch (error) {
+ console.error('설문조사 템플릿 로드 실패:', error);
+ return null;
+ }
+}
+
+
+// 설문 답변 데이터 타입 정의
+export interface SurveyAnswerData {
+ questionId: number;
+ answerValue?: string;
+ detailText?: string;
+ otherText?: string;
+ files?: File[];
+}
+
+// 설문조사 완료 요청 데이터 타입
+export interface CompleteSurveyRequest {
+ contractId: number;
+ templateId: number;
+ answers: SurveyAnswerData[];
+ progressStatus?: any; // 진행 상태 정보 (옵션)
+}
+
+// 서버 액션: 설문조사 완료 처리
+export async function completeSurvey(data: CompleteSurveyRequest) {
+ try {
+ console.log('🚀 설문조사 완료 처리 시작:', {
+ contractId: data.contractId,
+ templateId: data.templateId,
+ answersCount: data.answers?.length || 0
+ });
+
+ // 입력 검증
+ if (!data.contractId || !data.templateId || !data.answers?.length) {
+ throw new Error('필수 데이터가 누락되었습니다.');
+ }
+
+ // 트랜잭션으로 처리
+ const result = await db.transaction(async (tx) => {
+ // 1. complianceResponses 테이블 upsert
+ console.log('📋 complianceResponses 처리 중...');
+
+ // 기존 응답 확인
+ const existingResponse = await tx
+ .select()
+ .from(complianceResponses)
+ .where(
+ and(
+ eq(complianceResponses.basicContractId, data.contractId),
+ eq(complianceResponses.templateId, data.templateId)
+ )
+ )
+ .limit(1);
+
+ let responseId: number;
+
+ if (existingResponse.length > 0) {
+ // 기존 응답 업데이트
+ const updateData = {
+ status: 'COMPLETED' as const,
+ completedAt: new Date(),
+ updatedAt: new Date()
+ };
+
+ await tx
+ .update(complianceResponses)
+ .set(updateData)
+ .where(eq(complianceResponses.id, existingResponse[0].id));
+
+ responseId = existingResponse[0].id;
+ console.log(`✅ 기존 응답 업데이트 완료: ID ${responseId}`);
+ } else {
+ // 새 응답 생성
+ const newResponse: NewComplianceResponse = {
+ basicContractId: data.contractId,
+ templateId: data.templateId,
+ status: 'COMPLETED',
+ completedAt: new Date()
+ };
+
+ const insertResult = await tx
+ .insert(complianceResponses)
+ .values(newResponse)
+ .returning({ id: complianceResponses.id });
+
+ responseId = insertResult[0].id;
+ console.log(`✅ 새 응답 생성 완료: ID ${responseId}`);
+ }
+
+ // 2. 기존 답변들 삭제 (파일도 함께 삭제됨 - CASCADE 설정 필요)
+ console.log('🗑️ 기존 답변들 삭제 중...');
+
+ // 먼저 기존 답변에 연결된 파일들 삭제
+ const existingAnswers = await tx
+ .select({ id: complianceResponseAnswers.id })
+ .from(complianceResponseAnswers)
+ .where(eq(complianceResponseAnswers.responseId, responseId));
+
+ if (existingAnswers.length > 0) {
+ const answerIds = existingAnswers.map(a => a.id);
+
+ // 파일들 먼저 삭제
+ for (const answerId of answerIds) {
+ await tx
+ .delete(complianceResponseFiles)
+ .where(eq(complianceResponseFiles.answerId, answerId));
+ }
+
+ // 답변들 삭제
+ await tx
+ .delete(complianceResponseAnswers)
+ .where(eq(complianceResponseAnswers.responseId, responseId));
+ }
+
+ // 3. 새로운 답변들 생성
+ console.log('📝 새로운 답변들 생성 중...');
+ const createdAnswers: { questionId: number; answerId: number; files?: File[] }[] = [];
+
+ for (const answer of data.answers) {
+ // 빈 답변은 스킵 (선택적 질문의 경우)
+ if (!answer.answerValue && !answer.detailText && (!answer.files || answer.files.length === 0)) {
+ continue;
+ }
+
+ const newAnswer: NewComplianceResponseAnswer = {
+ responseId,
+ questionId: answer.questionId,
+ answerValue: answer.answerValue || null,
+ detailText: answer.detailText || null,
+ otherText: answer.otherText || null,
+ // percentageValue는 필요시 추가 처리
+ };
+
+ const answerResult = await tx
+ .insert(complianceResponseAnswers)
+ .values(newAnswer)
+ .returning({ id: complianceResponseAnswers.id });
+
+ const answerId = answerResult[0].id;
+
+ createdAnswers.push({
+ questionId: answer.questionId,
+ answerId,
+ files: answer.files
+ });
+
+ console.log(`✅ 답변 생성: 질문 ${answer.questionId} -> 답변 ID ${answerId}`);
+ }
+
+ // 4. 파일 업로드 처리 (실제 파일 저장은 별도 로직 필요)
+ console.log('📎 파일 업로드 처리 중...');
+
+ for (const answerWithFiles of createdAnswers) {
+ if (answerWithFiles.files && answerWithFiles.files.length > 0) {
+ for (const file of answerWithFiles.files) {
+ // TODO: 실제 파일 저장 로직 구현 필요
+ // 현재는 파일 메타데이터만 저장
+
+
+ // 파일 저장 경로 생성 (예시)
+ const fileName = file.name;
+ const filePath = `/uploads/compliance/${data.contractId}/${responseId}/${answerWithFiles.answerId}/${fileName}`;
+
+ const fileUpload = await saveFile({file,filePath })
+
+ const newFile: NewComplianceResponseFile = {
+ answerId: answerWithFiles.answerId,
+ fileName,
+ filePath,
+ fileSize: file.size,
+ mimeType: file.type || 'application/octet-stream'
+ };
+
+ await tx
+ .insert(complianceResponseFiles)
+ .values(newFile);
+
+ console.log(`📎 파일 메타데이터 저장: ${fileName}`);
+ }
+ }
+ }
+
+ return {
+ responseId,
+ answersCount: createdAnswers.length,
+ success: true
+ };
+ });
+
+ console.log('🎉 설문조사 완료 처리 성공:', result);
+
+
+ return {
+ success: true,
+ message: '설문조사가 성공적으로 완료되었습니다.',
+ data: result
+ };
+
+ } catch (error) {
+ console.error('❌ 설문조사 완료 처리 실패:', error);
+
+ return {
+ success: false,
+ message: error instanceof Error ? error.message : '설문조사 저장에 실패했습니다.',
+ data: null
+ };
+ }
+}
+
+// 설문조사 응답 조회 서버 액션
+export async function getSurveyResponse(contractId: number, templateId: number) {
+ try {
+ const response = await db
+ .select()
+ .from(complianceResponses)
+ .where(
+ and(
+ eq(complianceResponses.basicContractId, contractId),
+ eq(complianceResponses.templateId, templateId)
+ )
+ )
+ .limit(1);
+
+ if (response.length === 0) {
+ return { success: true, data: null };
+ }
+
+ // 답변들과 파일들도 함께 조회
+ const answers = await db
+ .select({
+ id: complianceResponseAnswers.id,
+ questionId: complianceResponseAnswers.questionId,
+ answerValue: complianceResponseAnswers.answerValue,
+ detailText: complianceResponseAnswers.detailText,
+ otherText: complianceResponseAnswers.otherText,
+ percentageValue: complianceResponseAnswers.percentageValue,
+ })
+ .from(complianceResponseAnswers)
+ .where(eq(complianceResponseAnswers.responseId, response[0].id));
+
+ // 각 답변의 파일들 조회
+ const answersWithFiles = await Promise.all(
+ answers.map(async (answer) => {
+ const files = await db
+ .select()
+ .from(complianceResponseFiles)
+ .where(eq(complianceResponseFiles.answerId, answer.id));
+
+ return {
+ ...answer,
+ files
+ };
+ })
+ );
+
+ return {
+ success: true,
+ data: {
+ response: response[0],
+ answers: answersWithFiles
+ }
+ };
+
+ } catch (error) {
+ console.error('❌ 설문조사 응답 조회 실패:', error);
+
+ return {
+ success: false,
+ message: error instanceof Error ? error.message : '설문조사 응답 조회에 실패했습니다.',
+ data: null
+ };
+ }
+}
+
+// 파일 업로드를 위한 별도 서버 액션 (실제 파일 저장 로직)
+export async function uploadSurveyFile(file: File, contractId: number, answerId: number) {
+ try {
+ // TODO: 실제 파일 저장 구현
+ // 예: AWS S3, 로컬 파일시스템, 등등
+
+ // 현재는 예시 구현
+ const fileName = `${Date.now()}-${file.name}`;
+ const filePath = `/uploads/compliance/${contractId}/${answerId}/${fileName}`;
+
+ // 실제로는 여기서 파일을 물리적으로 저장해야 함
+ // const savedPath = await saveFileToStorage(file, filePath);
+
+ return {
+ success: true,
+ filePath,
+ fileName: file.name,
+ fileSize: file.size,
+ mimeType: file.type
+ };
+
+ } catch (error) {
+ console.error('❌ 파일 업로드 실패:', error);
+
+ return {
+ success: false,
+ message: error instanceof Error ? error.message : '파일 업로드에 실패했습니다.'
+ };
+ }
+} \ No newline at end of file
diff --git a/lib/basic-contract/vendor-table/basic-contract-sign-dialog.tsx b/lib/basic-contract/vendor-table/basic-contract-sign-dialog.tsx
index 7d828a7e..319ae4b9 100644
--- a/lib/basic-contract/vendor-table/basic-contract-sign-dialog.tsx
+++ b/lib/basic-contract/vendor-table/basic-contract-sign-dialog.tsx
@@ -19,7 +19,10 @@ import {
User,
AlertCircle,
Calendar,
- Loader2
+ Loader2,
+ ArrowRight,
+ Trophy,
+ Target
} from "lucide-react";
import { Input } from "@/components/ui/input";
import { Badge } from "@/components/ui/badge";
@@ -28,6 +31,13 @@ import { useRouter } from "next/navigation"
import { BasicContractSignViewer } from "../viewer/basic-contract-sign-viewer";
import { getVendorAttachments } from "../service";
+// 계약서 상태 타입 정의
+interface ContractStatus {
+ id: number;
+ status: 'pending' | 'completed' | 'error';
+ errorMessage?: string;
+}
+
interface BasicContractSignDialogProps {
contracts: BasicContractView[];
onSuccess?: () => void;
@@ -51,6 +61,13 @@ export function BasicContractSignDialog({
const [additionalFiles, setAdditionalFiles] = React.useState<any[]>([]);
const [isLoadingAttachments, setIsLoadingAttachments] = React.useState(false);
+ // 계약서 상태 관리
+ const [contractStatuses, setContractStatuses] = React.useState<ContractStatus[]>([]);
+
+ // 🔥 새로 추가: 서명/설문 완료 상태 관리
+ const [surveyCompletionStatus, setSurveyCompletionStatus] = React.useState<Record<number, boolean>>({});
+ const [signatureStatus, setSignatureStatus] = React.useState<Record<number, boolean>>({});
+
const router = useRouter()
console.log(selectedContract,"selectedContract")
@@ -70,15 +87,81 @@ export function BasicContractSignDialog({
return "";
};
+ // 🔥 현재 선택된 계약서의 서명 완료 가능 여부 확인
+ const canCompleteCurrentContract = React.useMemo(() => {
+ if (!selectedContract) return false;
+
+ const contractId = selectedContract.id;
+ const isComplianceTemplate = selectedContract.templateName?.includes('준법');
+
+ // 1. 준법 템플릿인 경우 설문조사 완료 여부 확인
+ const surveyCompleted = isComplianceTemplate ? surveyCompletionStatus[contractId] === true : true;
+
+ // 2. 서명 완료 여부 확인
+ const signatureCompleted = signatureStatus[contractId] === true;
+
+ console.log('🔍 서명 완료 가능 여부 체크:', {
+ contractId,
+ isComplianceTemplate,
+ surveyCompleted,
+ signatureCompleted,
+ canComplete: surveyCompleted && signatureCompleted
+ });
+
+ return surveyCompleted && signatureCompleted;
+ }, [selectedContract, surveyCompletionStatus, signatureStatus]);
+
+ // 계약서별 상태 초기화
+ React.useEffect(() => {
+ if (contracts.length > 0 && contractStatuses.length === 0) {
+ setContractStatuses(
+ contracts.map(contract => ({
+ id: contract.id,
+ status: 'pending' as const
+ }))
+ );
+ }
+ }, [contracts, contractStatuses.length]);
+
+ // 완료된 계약서 수 계산
+ const completedCount = contractStatuses.filter(status => status.status === 'completed').length;
+ const totalCount = contracts.length;
+ const allCompleted = completedCount === totalCount && totalCount > 0;
+
+ // 현재 선택된 계약서의 상태
+ const currentContractStatus = selectedContract
+ ? contractStatuses.find(status => status.id === selectedContract.id)
+ : null;
+
+ // 다음 미완료 계약서 찾기
+ const getNextPendingContract = () => {
+ const pendingStatuses = contractStatuses.filter(status => status.status === 'pending');
+ if (pendingStatuses.length === 0) return null;
+
+ const nextPendingId = pendingStatuses[0].id;
+ return contracts.find(contract => contract.id === nextPendingId) || null;
+ };
+
// 다이얼로그 열기/닫기 핸들러
const handleOpenChange = (isOpen: boolean) => {
+ if (!isOpen && !allCompleted && completedCount > 0) {
+ // 완료되지 않은 계약서가 있으면 확인 대화상자
+ const confirmClose = window.confirm(
+ `${completedCount}/${totalCount}개 계약서가 완료되었습니다. 정말 나가시겠습니까?`
+ );
+ if (!confirmClose) return;
+ }
+
setOpen(isOpen);
if (!isOpen) {
// 다이얼로그 닫을 때 상태 초기화
setSelectedContract(null);
setSearchTerm("");
- setAdditionalFiles([]); // 추가 파일 상태 초기화
+ setAdditionalFiles([]);
+ setContractStatuses([]);
+ setSurveyCompletionStatus({}); // 🔥 추가
+ setSignatureStatus({}); // 🔥 추가
// WebViewer 인스턴스 정리
if (instance) {
try {
@@ -108,12 +191,17 @@ export function BasicContractSignDialog({
);
}, [contracts, searchTerm]);
- // 다이얼로그가 열릴 때 첫 번째 계약서 자동 선택
+ // 다이얼로그가 열릴 때 첫 번째 미완료 계약서 자동 선택
React.useEffect(() => {
if (open && contracts.length > 0 && !selectedContract) {
- setSelectedContract(contracts[0]);
+ const firstPending = getNextPendingContract();
+ if (firstPending) {
+ setSelectedContract(firstPending);
+ } else {
+ setSelectedContract(contracts[0]);
+ }
}
- }, [open, contracts, selectedContract]);
+ }, [open, contracts, selectedContract, contractStatuses]);
// 추가 파일 가져오기 useEffect
React.useEffect(() => {
@@ -149,10 +237,54 @@ export function BasicContractSignDialog({
fetchAdditionalFiles();
}, [selectedContract]);
- // 서명 완료 핸들러
+ // 🔥 설문조사 완료 콜백 함수
+ const handleSurveyComplete = React.useCallback((contractId: number) => {
+ console.log(`📋 설문조사 완료: 계약서 ${contractId}`);
+ setSurveyCompletionStatus(prev => ({
+ ...prev,
+ [contractId]: true
+ }));
+ }, []);
+
+ // 🔥 서명 완료 콜백 함수
+ const handleSignatureComplete = React.useCallback((contractId: number) => {
+ console.log(`✍️ 서명 완료: 계약서 ${contractId}`);
+ setSignatureStatus(prev => ({
+ ...prev,
+ [contractId]: true
+ }));
+ }, []);
+
+ // 서명 완료 핸들러 (수정됨)
const completeSign = async () => {
if (!instance || !selectedContract) return;
+ // 🔥 서명 완료 가능 여부 재확인
+ if (!canCompleteCurrentContract) {
+ const contractId = selectedContract.id;
+ const isComplianceTemplate = selectedContract.templateName?.includes('준법');
+ const surveyCompleted = isComplianceTemplate ? surveyCompletionStatus[contractId] === true : true;
+ const signatureCompleted = signatureStatus[contractId] === true;
+
+ if (!surveyCompleted) {
+ toast.error("준법 설문조사를 먼저 완료해주세요.", {
+ description: "설문조사 탭에서 모든 필수 항목을 완료해주세요.",
+ icon: <AlertCircle className="h-5 w-5 text-red-500" />
+ });
+ return;
+ }
+
+ if (!signatureCompleted) {
+ toast.error("계약서에 서명을 먼저 완료해주세요.", {
+ description: "문서의 서명 필드에 서명해주세요.",
+ icon: <Target className="h-5 w-5 text-blue-500" />
+ });
+ return;
+ }
+
+ return;
+ }
+
setIsSubmitting(true);
try {
const { documentViewer, annotationManager } = instance.Core;
@@ -183,21 +315,6 @@ export function BasicContractSignDialog({
submitFormData.append('formData', JSON.stringify(formData));
}
- // 준법 템플릿인 경우 필수 필드 검증
- if (selectedContract.templateName?.includes('준법')) {
- const requiredFields = ['compliance_agreement', 'legal_review', 'risk_assessment'];
- const missingFields = requiredFields.filter(field => !formData[field]);
-
- if (missingFields.length > 0) {
- toast.error("필수 준법 항목이 누락되었습니다.", {
- description: `다음 항목을 완료해주세요: ${missingFields.join(', ')}`,
- icon: <AlertCircle className="h-5 w-5 text-red-500" />
- });
- setIsSubmitting(false);
- return;
- }
- }
-
// API 호출
const response = await fetch('/api/upload/signed-contract', {
method: 'POST',
@@ -208,29 +325,82 @@ export function BasicContractSignDialog({
const result = await response.json();
if (result.result) {
- toast.success(t("basicContracts.messages.signSuccess"), {
- description: t("basicContracts.messages.documentProcessed"),
+ // 성공시 해당 계약서 상태를 완료로 업데이트
+ setContractStatuses(prev =>
+ prev.map(status =>
+ status.id === selectedContract.id
+ ? { ...status, status: 'completed' as const }
+ : status
+ )
+ );
+
+ toast.success("계약서 서명이 완료되었습니다!", {
+ description: `${selectedContract.templateName} - ${completedCount + 1}/${totalCount}개 완료`,
icon: <CheckCircle2 className="h-5 w-5 text-green-500" />
});
- router.refresh();
- setOpen(false);
- if (onSuccess) {
- onSuccess();
+
+ // 다음 미완료 계약서로 자동 이동
+ const nextContract = getNextPendingContract();
+ if (nextContract) {
+ setSelectedContract(nextContract);
+ toast.info(`다음 계약서로 이동합니다`, {
+ description: nextContract.templateName,
+ icon: <ArrowRight className="h-4 w-4 text-blue-500" />
+ });
+ } else {
+ // 모든 계약서 완료시
+ toast.success("🎉 모든 계약서 서명이 완료되었습니다!", {
+ description: `총 ${totalCount}개 계약서 서명 완료`,
+ icon: <Trophy className="h-5 w-5 text-yellow-500" />
+ });
}
+
+ router.refresh();
} else {
- toast.error(t("basicContracts.messages.signError"), {
+ // 실패시 에러 상태 업데이트
+ setContractStatuses(prev =>
+ prev.map(status =>
+ status.id === selectedContract.id
+ ? { ...status, status: 'error' as const, errorMessage: result.error }
+ : status
+ )
+ );
+
+ toast.error("서명 처리 중 오류가 발생했습니다", {
description: result.error,
icon: <AlertCircle className="h-5 w-5 text-red-500" />
});
}
} catch (error) {
console.error("서명 완료 중 오류:", error);
- toast.error(t("basicContracts.messages.signError"));
+
+ // 에러 상태 업데이트
+ setContractStatuses(prev =>
+ prev.map(status =>
+ status.id === selectedContract.id
+ ? { ...status, status: 'error' as const, errorMessage: '서명 처리 중 오류가 발생했습니다' }
+ : status
+ )
+ );
+
+ toast.error("서명 처리 중 오류가 발생했습니다");
} finally {
setIsSubmitting(false);
}
};
+ // 모든 서명 완료 핸들러
+ const completeAllSigns = () => {
+ setOpen(false);
+ if (onSuccess) {
+ onSuccess();
+ }
+ toast.success("모든 계약서 서명이 완료되었습니다!", {
+ description: "계약서 관리 페이지가 새로고침됩니다.",
+ icon: <Trophy className="h-5 w-5 text-yellow-500" />
+ });
+ };
+
return (
<>
{/* 서명 버튼 */}
@@ -260,19 +430,48 @@ export function BasicContractSignDialog({
</span>
</Button>
- {/* 서명 다이얼로그 - 레이아웃 개선 */}
+ {/* 서명 다이얼로그 */}
<Dialog open={open} onOpenChange={handleOpenChange}>
<DialogContent className="max-w-7xl w-[95vw] h-[90vh] p-0 flex flex-col overflow-hidden" style={{width:'95vw', maxWidth:'95vw'}}>
- {/* 고정 헤더 */}
+ {/* 고정 헤더 - 진행 상황 표시 */}
<DialogHeader className="px-6 py-4 bg-gradient-to-r from-blue-50 to-purple-50 border-b flex-shrink-0">
- <DialogTitle className="text-xl font-bold flex items-center text-gray-800">
- <FileSignature className="mr-2 h-5 w-5 text-blue-500" />
- {t("basicContracts.dialog.title")}
- {/* 추가 파일 로딩 표시 */}
- {isLoadingAttachments && (
- <Loader2 className="ml-2 h-4 w-4 animate-spin text-blue-500" />
+ <DialogTitle className="text-xl font-bold flex items-center justify-between text-gray-800">
+ <div className="flex items-center">
+ <FileSignature className="mr-2 h-5 w-5 text-blue-500" />
+ {t("basicContracts.dialog.title")}
+ {/* 진행 상황 표시 */}
+ <Badge variant="outline" className="ml-3 bg-blue-50 text-blue-700 border-blue-200">
+ {completedCount}/{totalCount} 완료
+ </Badge>
+ {/* 추가 파일 로딩 표시 */}
+ {isLoadingAttachments && (
+ <Loader2 className="ml-2 h-4 w-4 animate-spin text-blue-500" />
+ )}
+ </div>
+
+ {allCompleted && (
+ <Badge variant="default" className="bg-green-100 text-green-700 border-green-200">
+ <Trophy className="h-4 w-4 mr-1" />
+ 전체 완료!
+ </Badge>
)}
</DialogTitle>
+
+ {/* 진행률 바 */}
+ {totalCount > 1 && (
+ <div className="mt-3">
+ <div className="flex justify-between text-xs text-gray-600 mb-1">
+ <span>전체 진행률</span>
+ <span>{Math.round((completedCount / totalCount) * 100)}%</span>
+ </div>
+ <div className="w-full bg-gray-200 rounded-full h-2">
+ <div
+ className="bg-gradient-to-r from-blue-500 to-green-500 h-2 rounded-full transition-all duration-500"
+ style={{ width: `${(completedCount / totalCount) * 100}%` }}
+ />
+ </div>
+ </div>
+ )}
</DialogHeader>
{/* 메인 컨텐츠 영역 - Flexbox 사용 */}
@@ -302,49 +501,101 @@ export function BasicContractSignDialog({
</div>
) : (
<div className="space-y-2">
- {filteredContracts.map((contract) => (
- <Button
- key={contract.id}
- variant="outline"
- className={cn(
- "w-full justify-start text-left h-auto p-2 bg-white hover:bg-blue-50 transition-colors",
- "border border-gray-200 hover:border-blue-200 rounded-md",
- selectedContract?.id === contract.id && "border-blue-500 bg-blue-50 shadow-sm"
- )}
- onClick={() => handleSelectContract(contract)}
- >
- <div className="flex flex-col w-full space-y-1">
- {/* 첫 번째 줄: 제목 + 상태 */}
- <div className="flex items-center justify-between w-full">
- <span className="font-medium text-xs truncate text-gray-800 flex items-center min-w-0">
- <FileText className="h-3 w-3 mr-1 text-blue-500 flex-shrink-0" />
- <span className="truncate">{contract.templateName || t("basicContracts.dialog.document")}</span>
- {/* 비밀유지 계약서인 경우 표시 */}
- {contract.templateName === "비밀유지 계약서" && (
- <Badge variant="outline" className="ml-1 bg-green-50 text-green-700 border-green-200 text-xs">
- NDA
+ {filteredContracts.map((contract) => {
+ const contractStatus = contractStatuses.find(status => status.id === contract.id);
+ const isCompleted = contractStatus?.status === 'completed';
+ const hasError = contractStatus?.status === 'error';
+
+ // 🔥 계약서별 완료 상태 확인
+ const isComplianceTemplate = contract.templateName?.includes('준법');
+ const hasSurveyCompleted = isComplianceTemplate ? surveyCompletionStatus[contract.id] === true : true;
+ const hasSignatureCompleted = signatureStatus[contract.id] === true;
+
+ return (
+ <Button
+ key={contract.id}
+ variant="outline"
+ className={cn(
+ "w-full justify-start text-left h-auto p-2 bg-white transition-colors",
+ "border border-gray-200 rounded-md",
+ selectedContract?.id === contract.id && !isCompleted && "border-blue-500 bg-blue-50 shadow-sm",
+ isCompleted && "border-green-200 bg-green-50",
+ hasError && "border-red-200 bg-red-50",
+ !isCompleted && !hasError && "hover:bg-blue-50 hover:border-blue-200"
+ )}
+ onClick={() => handleSelectContract(contract)}
+ disabled={isCompleted}
+ >
+ <div className="flex flex-col w-full space-y-1">
+ {/* 첫 번째 줄: 제목 + 상태 */}
+ <div className="flex items-center justify-between w-full">
+ <span className="font-medium text-xs truncate text-gray-800 flex items-center min-w-0">
+ <FileText className="h-3 w-3 mr-1 text-blue-500 flex-shrink-0" />
+ <span className="truncate">{contract.templateName || t("basicContracts.dialog.document")}</span>
+ {/* 비밀유지 계약서인 경우 표시 */}
+ {contract.templateName === "비밀유지 계약서" && (
+ <Badge variant="outline" className="ml-1 bg-green-50 text-green-700 border-green-200 text-xs">
+ NDA
+ </Badge>
+ )}
+ </span>
+
+ {/* 상태 표시 */}
+ {isCompleted ? (
+ <Badge variant="outline" className="bg-green-50 text-green-700 border-green-200 text-xs ml-2 flex-shrink-0">
+ <CheckCircle2 className="h-3 w-3 mr-1" />
+ 완료
+ </Badge>
+ ) : hasError ? (
+ <Badge variant="outline" className="bg-red-50 text-red-700 border-red-200 text-xs ml-2 flex-shrink-0">
+ <AlertCircle className="h-3 w-3 mr-1" />
+ 오류
+ </Badge>
+ ) : (
+ <Badge variant="outline" className="bg-yellow-50 text-yellow-700 border-yellow-200 text-xs ml-2 flex-shrink-0">
+ 대기
</Badge>
)}
- </span>
- <Badge variant="outline" className="bg-yellow-50 text-yellow-700 border-yellow-200 text-xs ml-2 flex-shrink-0">
- {t("basicContracts.statusValues.PENDING")}
- </Badge>
- </div>
-
- {/* 두 번째 줄: 사용자 + 날짜 */}
- <div className="flex items-center justify-between text-xs text-gray-500">
- <div className="flex items-center min-w-0">
- <User className="h-3 w-3 mr-1 flex-shrink-0" />
- <span className="truncate">{contract.requestedByName || t("basicContracts.dialog.unknown")}</span>
</div>
- <div className="flex items-center ml-2 flex-shrink-0">
- <Calendar className="h-3 w-3 mr-1 flex-shrink-0" />
- <span>{formatDate(contract.createdAt)}</span>
+
+ {/* 🔥 완료 상태 표시 */}
+ {!isCompleted && !hasError && (
+ <div className="flex items-center space-x-2 text-xs">
+ {isComplianceTemplate && (
+ <span className={`flex items-center ${hasSurveyCompleted ? 'text-green-600' : 'text-gray-400'}`}>
+ <CheckCircle2 className={`h-3 w-3 mr-1 ${hasSurveyCompleted ? 'text-green-500' : 'text-gray-300'}`} />
+ 설문
+ </span>
+ )}
+ <span className={`flex items-center ${hasSignatureCompleted ? 'text-green-600' : 'text-gray-400'}`}>
+ <Target className={`h-3 w-3 mr-1 ${hasSignatureCompleted ? 'text-green-500' : 'text-gray-300'}`} />
+ 서명
+ </span>
+ </div>
+ )}
+
+ {/* 두 번째 줄: 사용자 + 날짜 */}
+ <div className="flex items-center justify-between text-xs text-gray-500">
+ <div className="flex items-center min-w-0">
+ <User className="h-3 w-3 mr-1 flex-shrink-0" />
+ <span className="truncate">{contract.requestedByName || t("basicContracts.dialog.unknown")}</span>
+ </div>
+ <div className="flex items-center ml-2 flex-shrink-0">
+ <Calendar className="h-3 w-3 mr-1 flex-shrink-0" />
+ <span>{formatDate(contract.createdAt)}</span>
+ </div>
</div>
+
+ {/* 에러 메시지 표시 */}
+ {hasError && contractStatus?.errorMessage && (
+ <div className="text-xs text-red-600 mt-1">
+ {contractStatus.errorMessage}
+ </div>
+ )}
</div>
- </div>
- </Button>
- ))}
+ </Button>
+ );
+ })}
</div>
)}
</div>
@@ -360,12 +611,31 @@ export function BasicContractSignDialog({
<h3 className="font-semibold text-gray-800 flex items-center">
<FileText className="h-4 w-4 mr-2 text-blue-500" />
{selectedContract.templateName || t("basicContracts.dialog.document")}
+
+ {/* 현재 계약서 상태 표시 */}
+ {currentContractStatus?.status === 'completed' ? (
+ <Badge variant="outline" className="ml-2 bg-green-50 text-green-700 border-green-200">
+ <CheckCircle2 className="h-3 w-3 mr-1" />
+ 서명 완료
+ </Badge>
+ ) : currentContractStatus?.status === 'error' ? (
+ <Badge variant="outline" className="ml-2 bg-red-50 text-red-700 border-red-200">
+ <AlertCircle className="h-3 w-3 mr-1" />
+ 처리 실패
+ </Badge>
+ ) : (
+ <Badge variant="outline" className="ml-2 bg-yellow-50 text-yellow-700 border-yellow-200">
+ 서명 대기
+ </Badge>
+ )}
+
{/* 준법 템플릿 표시 */}
{selectedContract.templateName?.includes('준법') && (
<Badge variant="outline" className="ml-2 bg-amber-50 text-amber-700 border-amber-200">
준법 서류
</Badge>
)}
+
{/* 비밀유지 계약서인 경우 추가 파일 수 표시 */}
{selectedContract.templateName === "비밀유지 계약서" && additionalFiles.length > 0 && (
<Badge variant="outline" className="ml-2 bg-blue-50 text-blue-700 border-blue-200">
@@ -388,59 +658,128 @@ export function BasicContractSignDialog({
{/* 뷰어 영역 - 남은 공간 모두 사용 */}
<div className="flex-1 min-h-0 overflow-hidden">
<BasicContractSignViewer
- key={selectedContract.id} // key 추가로 컴포넌트 재생성 강제
+ key={selectedContract.id}
contractId={selectedContract.id}
filePath={selectedContract.signedFilePath || undefined}
templateName={selectedContract.templateName || ""}
- additionalFiles={additionalFiles} // 추가 파일 전달
+ additionalFiles={additionalFiles}
instance={instance}
setInstance={setInstance}
+ onSurveyComplete={() => handleSurveyComplete(selectedContract.id)} // 🔥 추가
+ onSignatureComplete={() => handleSignatureComplete(selectedContract.id)} // 🔥 추가
t={t}
/>
</div>
- {/* 고정 푸터 */}
+ {/* 고정 푸터 - 동적 버튼 */}
<div className="p-4 flex justify-between items-center bg-gray-50 border-t flex-shrink-0">
<div className="flex items-center space-x-4">
- <p className="text-sm text-gray-600 flex items-center">
- <AlertCircle className="h-4 w-4 text-yellow-500 mr-1" />
- {t("basicContracts.dialog.signWarning")}
- </p>
- {/* 준법 템플릿인 경우 추가 안내 */}
- {selectedContract.templateName?.includes('준법') && (
- <p className="text-xs text-amber-600 flex items-center">
- <AlertCircle className="h-3 w-3 text-amber-500 mr-1" />
- 모든 준법 항목을 체크해주세요
+ {/* 현재 계약서가 완료된 경우 */}
+ {currentContractStatus?.status === 'completed' ? (
+ <p className="text-sm text-green-600 flex items-center">
+ <CheckCircle2 className="h-4 w-4 text-green-500 mr-1" />
+ 이 계약서는 이미 서명이 완료되었습니다
</p>
- )}
- {/* 비밀유지 계약서인 경우 추가 안내 */}
- {selectedContract.templateName === "비밀유지 계약서" && additionalFiles.length > 0 && (
- <p className="text-xs text-blue-600 flex items-center">
- <FileText className="h-3 w-3 text-blue-500 mr-1" />
- 첨부 서류도 확인해주세요
+ ) : currentContractStatus?.status === 'error' ? (
+ <p className="text-sm text-red-600 flex items-center">
+ <AlertCircle className="h-4 w-4 text-red-500 mr-1" />
+ 서명 처리 중 오류가 발생했습니다. 다시 시도해주세요.
</p>
- )}
- </div>
- <Button
- className="gap-2 bg-blue-600 hover:bg-blue-700 transition-colors"
- onClick={completeSign}
- disabled={isSubmitting}
- >
- {isSubmitting ? (
- <>
- <svg className="animate-spin -ml-1 mr-2 h-4 w-4 text-white" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
- <circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"></circle>
- <path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
- </svg>
- {t("basicContracts.dialog.processing")}
- </>
) : (
<>
- <FileSignature className="h-4 w-4" />
- {t("basicContracts.dialog.completeSign")}
+ {/* 🔥 완료 조건 안내 메시지 개선 */}
+ <div className="flex flex-col space-y-1">
+ <p className="text-sm text-gray-600 flex items-center">
+ <AlertCircle className="h-4 w-4 text-yellow-500 mr-1" />
+ {t("basicContracts.dialog.signWarning")}
+ </p>
+
+ {/* 완료 상태 체크리스트 */}
+ <div className="flex items-center space-x-4 text-xs">
+ {selectedContract.templateName?.includes('준법') && (
+ <span className={`flex items-center ${surveyCompletionStatus[selectedContract.id] ? 'text-green-600' : 'text-red-600'}`}>
+ <CheckCircle2 className={`h-3 w-3 mr-1 ${surveyCompletionStatus[selectedContract.id] ? 'text-green-500' : 'text-red-500'}`} />
+ 설문조사 {surveyCompletionStatus[selectedContract.id] ? '완료' : '미완료'}
+ </span>
+ )}
+ <span className={`flex items-center ${signatureStatus[selectedContract.id] ? 'text-green-600' : 'text-red-600'}`}>
+ <Target className={`h-3 w-3 mr-1 ${signatureStatus[selectedContract.id] ? 'text-green-500' : 'text-red-500'}`} />
+ 서명 {signatureStatus[selectedContract.id] ? '완료' : '미완료'}
+ </span>
+ </div>
+ </div>
+
+ {/* 비밀유지 계약서인 경우 추가 안내 */}
+ {selectedContract.templateName === "비밀유지 계약서" && additionalFiles.length > 0 && (
+ <p className="text-xs text-blue-600 flex items-center">
+ <FileText className="h-3 w-3 text-blue-500 mr-1" />
+ 첨부 서류도 확인해주세요
+ </p>
+ )}
</>
)}
- </Button>
+ </div>
+
+ {/* 동적 버튼 영역 */}
+ <div className="flex items-center space-x-2">
+ {allCompleted ? (
+ // 모든 계약서 완료시
+ <Button
+ className="gap-2 bg-green-600 hover:bg-green-700 transition-colors"
+ onClick={completeAllSigns}
+ >
+ <Trophy className="h-4 w-4" />
+ 모든 서명 완료
+ </Button>
+ ) : currentContractStatus?.status === 'completed' ? (
+ // 현재 계약서가 완료된 경우
+ <Button
+ variant="outline"
+ className="gap-2"
+ onClick={() => {
+ const nextContract = getNextPendingContract();
+ if (nextContract) {
+ setSelectedContract(nextContract);
+ }
+ }}
+ disabled={!getNextPendingContract()}
+ >
+ <ArrowRight className="h-4 w-4" />
+ 다음 계약서
+ </Button>
+ ) : (
+ // 현재 계약서를 서명해야 하는 경우
+ <Button
+ className={`gap-2 transition-colors ${
+ canCompleteCurrentContract
+ ? "bg-blue-600 hover:bg-blue-700"
+ : "bg-gray-400 cursor-not-allowed"
+ }`}
+ onClick={completeSign}
+ disabled={!canCompleteCurrentContract || isSubmitting} // 🔥 조건 수정
+ >
+ {isSubmitting ? (
+ <>
+ <svg className="animate-spin -ml-1 mr-2 h-4 w-4 text-white" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
+ <circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"></circle>
+ <path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
+ </svg>
+ 처리중...
+ </>
+ ) : (
+ <>
+ <FileSignature className="h-4 w-4" />
+ 서명 완료
+ {totalCount > 1 && (
+ <span className="ml-1 text-xs">
+ ({completedCount + 1}/{totalCount})
+ </span>
+ )}
+ </>
+ )}
+ </Button>
+ )}
+ </div>
</div>
</>
) : (
diff --git a/lib/basic-contract/vendor-table/basic-contract-table.tsx b/lib/basic-contract/vendor-table/basic-contract-table.tsx
index f2575024..48298f21 100644
--- a/lib/basic-contract/vendor-table/basic-contract-table.tsx
+++ b/lib/basic-contract/vendor-table/basic-contract-table.tsx
@@ -38,7 +38,6 @@ export function BasicContractsVendorTable({ promises }: BasicTemplateTableProps)
const [{ data, pageCount }] =
React.use(promises)
- console.log(data,"data")
// 안전한 번역 함수 (fallback 포함)
const safeT = React.useCallback((key: string, fallback: string) => {
diff --git a/lib/basic-contract/vendor-table/survey-conditional.ts b/lib/basic-contract/vendor-table/survey-conditional.ts
new file mode 100644
index 00000000..71c2c1ff
--- /dev/null
+++ b/lib/basic-contract/vendor-table/survey-conditional.ts
@@ -0,0 +1,860 @@
+// lib/utils/survey-conditional.ts
+import type { SurveyQuestion, SurveyTemplateWithQuestions } from '../service';
+
+/**
+ * 조건부 질문 처리를 위한 유틸리티 클래스
+ */
+export class ConditionalSurveyHandler {
+ private questions: SurveyQuestion[];
+ private parentChildMap: Map<number, number[]>;
+ private childParentMap: Map<number, { parentId: number; conditionalValue: string }>;
+
+ constructor(template: SurveyTemplateWithQuestions) {
+ this.questions = template.questions;
+ this.parentChildMap = new Map();
+ this.childParentMap = new Map();
+
+ this.buildRelationshipMaps();
+ }
+
+ /**
+ * 부모-자식 관계 맵 구축
+ */
+ private buildRelationshipMaps() {
+ this.questions.forEach(question => {
+ if (question.parentQuestionId && question.conditionalValue) {
+ // 자식 -> 부모 맵핑
+ this.childParentMap.set(question.id, {
+ parentId: question.parentQuestionId,
+ conditionalValue: question.conditionalValue
+ });
+
+ // 부모 -> 자식들 맵핑
+ const existingChildren = this.parentChildMap.get(question.parentQuestionId) || [];
+ this.parentChildMap.set(question.parentQuestionId, [...existingChildren, question.id]);
+ }
+ });
+ }
+
+
+ /**
+ * 질문이 완료되지 않은 이유를 반환 (디버깅용)
+ */
+public getIncompleteReason(question: SurveyQuestion, surveyAnswers: Record<number, any>): string {
+ const answer = surveyAnswers[question.id];
+
+ if (!answer?.answerValue) return '답변이 없음';
+
+ if (answer.answerValue === 'OTHER' && !answer.otherText?.trim()) {
+ return '기타 내용 입력 필요';
+ }
+
+ if (question.hasDetailText && ['YES', '네', 'Y'].includes(answer.answerValue) && !answer.detailText?.trim()) {
+ return '상세 내용 입력 필요';
+ }
+
+ if ((question.hasFileUpload || question.questionType === 'FILE') &&
+ ['YES', '네', 'Y'].includes(answer.answerValue) &&
+ (!answer.files || answer.files.length === 0)) {
+ return '파일 업로드 필요';
+ }
+
+ // ⭐ 조건부 자식 질문들 체크
+ const childQuestions = this.getChildQuestions(question.id);
+ const triggeredChildren = childQuestions.filter(child =>
+ child.conditionalValue === answer.answerValue && child.isRequired
+ );
+
+ for (const childQuestion of triggeredChildren) {
+ if (!this.isQuestionComplete(childQuestion, surveyAnswers)) {
+ return `조건부 질문 "${childQuestion.questionText?.substring(0, 30)}..."이 미완료`;
+ }
+ }
+
+ return '완료됨';
+ }
+
+ /**
+ * 조건부 질문을 위한 단순 완료 체크 (자식 질문 체크 제외)
+ */
+ private isSimpleQuestionComplete(question: SurveyQuestion, surveyAnswers: Record<number, any>): boolean {
+ const answer = surveyAnswers[question.id];
+
+ console.log(`🔍 조건부 질문 단순 완료 체크 [Q${question.questionNumber}]:`, {
+ questionId: question.id,
+ questionNumber: question.questionNumber,
+ isRequired: question.isRequired,
+ hasAnswer: !!answer,
+ answerValue: answer?.answerValue,
+ parentId: question.parentQuestionId,
+ conditionalValue: question.conditionalValue
+ });
+
+ if (!question.isRequired) {
+ console.log(`✅ Q${question.questionNumber}: 선택 질문이므로 완료`);
+ return true;
+ }
+
+ if (!answer?.answerValue) {
+ console.log(`❌ Q${question.questionNumber}: 답변이 없음`);
+ return false;
+ }
+
+ // 1. '기타' 선택 시 추가 입력이 필요한 경우
+ if (answer.answerValue === 'OTHER' && !answer.otherText?.trim()) {
+ console.log(`❌ Q${question.questionNumber}: '기타' 선택했지만 상세 내용 없음`);
+ return false;
+ }
+
+ // 2. 상세 텍스트가 필요한 경우
+ if (question.hasDetailText) {
+ const needsDetailText = ['YES', '네', 'Y', 'true', '1'].includes(answer.answerValue);
+
+ if (needsDetailText && !answer.detailText?.trim()) {
+ console.log(`❌ Q${question.questionNumber}: '${answer.answerValue}' 선택했지만 상세 내용 없음`);
+ return false;
+ }
+ }
+
+ // 3. 파일 업로드가 필요한 경우
+ if (question.hasFileUpload || question.questionType === 'FILE') {
+ const needsFileUpload = ['YES', '네', 'Y', 'true', '1'].includes(answer.answerValue);
+
+ if (needsFileUpload && (!answer.files || answer.files.length === 0)) {
+ console.log(`❌ Q${question.questionNumber}: '${answer.answerValue}' 선택했지만 파일 업로드 없음`);
+ return false;
+ }
+ }
+
+ // 조건부 질문은 자식 질문 체크를 하지 않음 (무한 루프 방지)
+ console.log(`✅ Q${question.questionNumber} 조건부 질문 완료 체크 통과`);
+ return true;
+ }
+
+ private isQuestionCompleteEnhanced(question: SurveyQuestion, surveyAnswers: Record<number, any>): boolean {
+ const answer = surveyAnswers[question.id];
+
+ if (!answer) {
+ console.log(`❌ Q${question.questionNumber}: 답변 객체가 없음`);
+ return false;
+ }
+
+ console.log(`🔍 Q${question.questionNumber} 답변 상세 체크:`, {
+ answerValue: answer.answerValue,
+ detailText: answer.detailText,
+ otherText: answer.otherText,
+ files: answer.files,
+ filesLength: answer.files?.length
+ });
+
+ // 1️⃣ answerValue 체크 (빈 문자열이 아닌 경우)
+ const hasAnswerValue = answer.answerValue && answer.answerValue.trim() !== '';
+
+ // 2️⃣ detailText 체크 (빈 문자열이 아닌 경우)
+ const hasDetailText = answer.detailText && answer.detailText.trim() !== '';
+
+ // 3️⃣ otherText 체크 (OTHER 선택 시)
+ const hasOtherText = answer.answerValue === 'OTHER' ?
+ (answer.otherText && answer.otherText.trim() !== '') : true;
+
+ // 4️⃣ files 체크 (실제 파일이 있는 경우)
+ const hasValidFiles = answer.files && answer.files.length > 0 &&
+ answer.files.every((file: any) => file && typeof file === 'object' &&
+ (file.name || file.size || file.type)); // 빈 객체 {} 제외
+
+ console.log(`📊 Q${question.questionNumber} 완료 조건 체크:`, {
+ hasAnswerValue,
+ hasDetailText,
+ hasOtherText,
+ hasValidFiles,
+ questionType: question.questionType,
+ hasDetailTextRequired: question.hasDetailText,
+ hasFileUploadRequired: question.hasFileUpload || question.questionType === 'FILE'
+ });
+
+ // 🎯 질문 타입별 완료 조건
+ switch (question.questionType) {
+ case 'RADIO':
+ case 'DROPDOWN':
+ // 선택형: answerValue가 있고, OTHER인 경우 otherText도 필요
+ const isSelectComplete = hasAnswerValue && hasOtherText;
+
+ // detailText가 필요한 경우 추가 체크
+ if (question.hasDetailText && isSelectComplete) {
+ return hasDetailText;
+ }
+
+ // 파일 업로드가 필요한 경우 추가 체크
+ if ((question.hasFileUpload || question.questionType === 'FILE') && isSelectComplete) {
+ return hasValidFiles;
+ }
+
+ return isSelectComplete;
+
+ case 'TEXTAREA':
+ // 텍스트 영역: detailText 또는 answerValue 중 하나라도 있으면 됨
+ return hasDetailText || hasAnswerValue;
+
+ case 'FILE':
+ // 파일 업로드: 유효한 파일이 있어야 함
+ return hasValidFiles;
+
+ default:
+ // 기본: answerValue, detailText, 파일 중 하나라도 있으면 완료
+ let isComplete = hasAnswerValue || hasDetailText || hasValidFiles;
+
+ // detailText가 필수인 경우
+ if (question.hasDetailText) {
+ isComplete = isComplete && hasDetailText;
+ }
+
+ // 파일 업로드가 필수인 경우
+ if (question.hasFileUpload) {
+ isComplete = isComplete && hasValidFiles;
+ }
+
+ return isComplete;
+ }
+ }
+
+ // 🎯 개선된 미완료 이유 제공 함수
+private getIncompleteReasonEnhanced(question: SurveyQuestion, surveyAnswers: Record<number, any>): string {
+ const answer = surveyAnswers[question.id];
+
+ if (!answer) {
+ return '답변이 없습니다';
+ }
+
+ const hasAnswerValue = answer.answerValue && answer.answerValue.trim() !== '';
+ const hasDetailText = answer.detailText && answer.detailText.trim() !== '';
+ const hasValidFiles = answer.files && answer.files.length > 0 &&
+ answer.files.every((file: any) => file && typeof file === 'object' &&
+ (file.name || file.size || file.type));
+
+ const reasons: string[] = [];
+
+ switch (question.questionType) {
+ case 'RADIO':
+ case 'DROPDOWN':
+ if (!hasAnswerValue) {
+ reasons.push('선택 필요');
+ } else if (answer.answerValue === 'OTHER' && (!answer.otherText || answer.otherText.trim() === '')) {
+ reasons.push('기타 내용 입력 필요');
+ }
+ break;
+
+ case 'TEXTAREA':
+ if (!hasDetailText && !hasAnswerValue) {
+ reasons.push('텍스트 입력 필요');
+ }
+ break;
+
+ case 'FILE':
+ if (!hasValidFiles) {
+ reasons.push('파일 업로드 필요');
+ }
+ break;
+ }
+
+ // 추가 요구사항 체크
+ if (question.hasDetailText && !hasDetailText) {
+ reasons.push('상세 내용 입력 필요');
+ }
+
+ if ((question.hasFileUpload || question.questionType === 'FILE') && !hasValidFiles) {
+ reasons.push('파일 첨부 필요');
+ }
+
+ return reasons.length > 0 ? reasons.join(', ') : '알 수 없는 이유';
+}
+
+// 🎯 완료 상세 정보 제공 함수
+private getCompletionDetailsEnhanced(question: SurveyQuestion, answer: any): any {
+ if (!answer) return { status: 'no_answer' };
+
+ const hasAnswerValue = answer.answerValue && answer.answerValue.trim() !== '';
+ const hasDetailText = answer.detailText && answer.detailText.trim() !== '';
+ const hasValidFiles = answer.files && answer.files.length > 0 &&
+ answer.files.every((file: any) => file && typeof file === 'object' &&
+ (file.name || file.size || file.type));
+
+ return {
+ status: 'has_answer',
+ hasAnswerValue,
+ hasDetailText,
+ hasValidFiles,
+ answerValue: answer.answerValue,
+ detailTextLength: answer.detailText?.length || 0,
+ filesCount: answer.files?.length || 0,
+ validFilesCount: hasValidFiles ? answer.files.filter((file: any) =>
+ file && typeof file === 'object' && (file.name || file.size || file.type)).length : 0
+ };
+}
+
+ getSimpleProgressStatus(surveyAnswers: Record<number, any>): {
+ visibleQuestions: SurveyQuestion[];
+ totalRequired: number;
+ completedRequired: number;
+ completedQuestionIds: number[];
+ incompleteQuestionIds: number[];
+ progressPercentage: number;
+ debugInfo?: any;
+ } {
+ // 🎯 현재 답변 상태에 따라 표시되는 질문들 계산
+ const visibleQuestions = this.getVisibleQuestions(surveyAnswers);
+
+ console.log('🔍 표시되는 모든 질문들의 상세 정보:', visibleQuestions.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,
+ hasAnswer: !!surveyAnswers[q.id]?.answerValue,
+ answerValue: surveyAnswers[q.id]?.answerValue
+ })));
+
+ // 🚨 중요: 트리거된 조건부 질문들을 필수로 처리
+ const requiredQuestions = visibleQuestions.filter(q => {
+ // 기본적으로 필수인 질문들
+ if (q.isRequired) return true;
+
+ // 조건부 질문인 경우, 트리거되었다면 필수로 간주
+ if (q.parentQuestionId && q.conditionalValue) {
+ const parentAnswer = surveyAnswers[q.parentQuestionId];
+ const isTriggered = parentAnswer?.answerValue === q.conditionalValue;
+
+ if (isTriggered) {
+ console.log(`⚡ 조건부 질문 Q${q.questionNumber} (ID: ${q.id})를 필수로 처리 - 트리거됨`);
+ return true;
+ }
+ }
+
+ return false;
+ });
+
+ console.log('📊 필수 질문 필터링 결과:', {
+ 전체질문수: this.questions.length,
+ 표시되는질문수: visibleQuestions.length,
+ 원래필수질문: visibleQuestions.filter(q => q.isRequired).length,
+ 트리거된조건부질문: visibleQuestions.filter(q => {
+ if (!q.parentQuestionId || !q.conditionalValue) return false;
+ const parentAnswer = surveyAnswers[q.parentQuestionId];
+ return parentAnswer?.answerValue === q.conditionalValue;
+ }).length,
+ 최종필수질문: requiredQuestions.length,
+ 현재답변수: Object.keys(surveyAnswers).length,
+ 필수질문들: requiredQuestions.map(q => ({
+ id: q.id,
+ questionNumber: q.questionNumber,
+ isRequired: q.isRequired,
+ isConditional: !!q.parentQuestionId,
+ hasAnswer: !!surveyAnswers[q.id]?.answerValue,
+ 처리방식: q.isRequired ? '원래필수' : '트리거됨'
+ }))
+ });
+
+ const completedQuestionIds: number[] = [];
+ const incompleteQuestionIds: number[] = [];
+ const debugInfo: any = {};
+
+ // 🔍 각 필수 질문의 완료 상태 확인 (개선된 완료 체크 로직)
+ requiredQuestions.forEach(question => {
+ console.log(`🔍 필수 질문 체크 시작: Q${question.questionNumber} (ID: ${question.id}, isRequired: ${question.isRequired})`);
+
+ // 🎯 개선된 완료 체크: 모든 답변 형태를 고려
+ const isComplete = this.isQuestionCompleteEnhanced(question, surveyAnswers);
+
+ console.log(`📊 Q${question.questionNumber} 완료 상태: ${isComplete}`);
+ console.log(`📝 Q${question.questionNumber} 답변 내용:`, surveyAnswers[question.id]);
+
+ // 디버깅 정보 수집
+ debugInfo[question.id] = {
+ questionText: question.questionText,
+ questionNumber: question.questionNumber,
+ isRequired: question.isRequired,
+ isVisible: true,
+ isConditional: !!question.parentQuestionId,
+ parentQuestionId: question.parentQuestionId,
+ conditionalValue: question.conditionalValue,
+ answer: surveyAnswers[question.id],
+ isComplete: isComplete,
+ hasDetailText: question.hasDetailText,
+ hasFileUpload: question.hasFileUpload,
+ childQuestions: this.getChildQuestions(question.id).map(c => ({
+ id: c.id,
+ condition: c.conditionalValue,
+ required: c.isRequired
+ })),
+ incompleteReason: isComplete ? null : this.getIncompleteReasonEnhanced(question, surveyAnswers)
+ };
+
+ if (isComplete) {
+ completedQuestionIds.push(question.id);
+ } else {
+ incompleteQuestionIds.push(question.id);
+ }
+ });
+
+ const progressPercentage = requiredQuestions.length > 0
+ ? (completedQuestionIds.length / requiredQuestions.length) * 100
+ : 100;
+
+ const result = {
+ visibleQuestions,
+ totalRequired: requiredQuestions.length,
+ completedRequired: completedQuestionIds.length,
+ completedQuestionIds,
+ incompleteQuestionIds,
+ progressPercentage: Math.min(100, progressPercentage)
+ };
+
+ // 개발 환경에서만 디버깅 정보 포함
+ if (process.env.NODE_ENV === 'development') {
+ (result as any).debugInfo = debugInfo;
+
+ // 📋 상세한 진행 상황 로그
+ console.log('📋 최종 진행 상황:', {
+ 총필수질문: requiredQuestions.length,
+ 완료된질문: completedQuestionIds.length,
+ 미완료질문: incompleteQuestionIds.length,
+ 진행률: `${Math.round(progressPercentage)}%`,
+ 기본질문: visibleQuestions.filter(q => !q.parentQuestionId).length,
+ 조건부질문: visibleQuestions.filter(q => q.parentQuestionId).length,
+ 완료된기본질문: completedQuestionIds.filter(id => !visibleQuestions.find(q => q.id === id)?.parentQuestionId).length,
+ 완료된조건부질문: completedQuestionIds.filter(id => !!visibleQuestions.find(q => q.id === id)?.parentQuestionId).length,
+ 필수질문상세: requiredQuestions.map(q => ({
+ id: q.id,
+ questionNumber: q.questionNumber,
+ isRequired: q.isRequired,
+ isConditional: !!q.parentQuestionId,
+ isComplete: completedQuestionIds.includes(q.id)
+ }))
+ });
+
+ // 🔍 미완료 질문들의 구체적 이유
+ if (incompleteQuestionIds.length > 0) {
+ console.log('🔍 미완료 질문들:', incompleteQuestionIds.map(id => ({
+ id,
+ questionNumber: debugInfo[id]?.questionNumber,
+ text: debugInfo[id]?.questionText?.substring(0, 50) + '...',
+ reason: debugInfo[id]?.incompleteReason,
+ isConditional: !!debugInfo[id]?.parentQuestionId
+ })));
+ }
+
+ // ⚡ 조건부 질문 활성화 및 완료 현황
+ const conditionalQuestions = 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,
+ condition: q.conditionalValue,
+ parentAnswer: surveyAnswers[q.parentQuestionId!]?.answerValue,
+ isTriggered: surveyAnswers[q.parentQuestionId!]?.answerValue === q.conditionalValue,
+ hasAnswer: !!surveyAnswers[q.id]?.answerValue,
+ answerValue: surveyAnswers[q.id]?.answerValue,
+ detailText: surveyAnswers[q.id]?.detailText,
+ files: surveyAnswers[q.id]?.files,
+ isComplete: debugInfo[q.id]?.isComplete,
+ isIncludedInRequired: requiredQuestions.some(rq => rq.id === q.id),
+ completionDetails: this.getCompletionDetailsEnhanced(q, surveyAnswers[q.id])
+ })));
+ }
+ }
+
+ return result;
+ }
+
+ /**
+ * 전체 설문조사의 진행 상태를 계산 (조건부 질문 고려)
+ * 표시되지 않는 질문들은 자동으로 완료된 것으로 간주하여 프로그레스가 역행하지 않도록 함
+ */
+getOverallProgressStatus(surveyAnswers: Record<number, any>): {
+ totalQuestions: number;
+ completedQuestions: number;
+ visibleQuestions: number;
+ requiredVisible: number;
+ completedRequired: number;
+ completedQuestionIds: number[];
+ incompleteQuestionIds: number[];
+ progressPercentage: number;
+ } {
+ const allQuestions = this.questions;
+ const visibleQuestions = this.getVisibleQuestions(surveyAnswers);
+ const requiredQuestions = allQuestions.filter(q => q.isRequired);
+
+ const completedQuestionIds: number[] = [];
+ const incompleteQuestionIds: number[] = [];
+
+ let totalCompleted = 0;
+
+ allQuestions.forEach(question => {
+ const isVisible = visibleQuestions.some(vq => vq.id === question.id);
+
+ if (!isVisible && question.isRequired) {
+ // 조건에 맞지 않아 숨겨진 필수 질문들은 자동 완료로 간주
+ totalCompleted++;
+ completedQuestionIds.push(question.id);
+ } else if (isVisible && question.isRequired) {
+ // 표시되는 필수 질문들은 실제 완료 여부 체크
+ if (this.isQuestionComplete(question, surveyAnswers)) {
+ totalCompleted++;
+ completedQuestionIds.push(question.id);
+ } else {
+ incompleteQuestionIds.push(question.id);
+ }
+ } else if (!question.isRequired) {
+ // 선택 질문들은 완료된 것으로 간주
+ totalCompleted++;
+ }
+ });
+
+ const progressPercentage = requiredQuestions.length > 0
+ ? (totalCompleted / requiredQuestions.length) * 100
+ : 100;
+
+ return {
+ totalQuestions: allQuestions.length,
+ completedQuestions: totalCompleted,
+ visibleQuestions: visibleQuestions.length,
+ requiredVisible: visibleQuestions.filter(q => q.isRequired).length,
+ completedRequired: completedQuestionIds.length,
+ completedQuestionIds,
+ incompleteQuestionIds,
+ progressPercentage: Math.min(100, progressPercentage)
+ };
+ }
+
+ /**
+ * 현재 표시되는 질문들만의 완료 상태 (기존 메서드 유지)
+ */
+ getVisibleRequiredStatus(surveyAnswers: Record<number, any>): {
+ total: number;
+ completed: number;
+ completedQuestionIds: number[];
+ incompleteQuestionIds: number[];
+ } {
+ const visibleQuestions = this.getVisibleQuestions(surveyAnswers);
+ const requiredQuestions = visibleQuestions.filter(q => q.isRequired);
+
+ const completedQuestionIds: number[] = [];
+ const incompleteQuestionIds: number[] = [];
+
+ requiredQuestions.forEach(question => {
+ if (this.isQuestionComplete(question, surveyAnswers)) {
+ completedQuestionIds.push(question.id);
+ } else {
+ incompleteQuestionIds.push(question.id);
+ }
+ });
+
+ return {
+ total: requiredQuestions.length,
+ completed: completedQuestionIds.length,
+ completedQuestionIds,
+ incompleteQuestionIds
+ };
+ }
+ /**
+ * 현재 답변 상태에 따라 표시되어야 할 질문들 필터링
+ */
+ getVisibleQuestions(surveyAnswers: Record<number, any>): SurveyQuestion[] {
+ return this.questions.filter(question => this.shouldShowQuestion(question, surveyAnswers));
+ }
+
+ /**
+ * 특정 질문이 현재 표시되어야 하는지 판단
+ */
+ private shouldShowQuestion(question: SurveyQuestion, surveyAnswers: Record<number, any>): boolean {
+ // 최상위 질문 (부모가 없는 경우)
+ if (!question.parentQuestionId || !question.conditionalValue) {
+ return true;
+ }
+
+ const parentAnswer = surveyAnswers[question.parentQuestionId];
+ if (!parentAnswer || !parentAnswer.answerValue) {
+ return false;
+ }
+
+ // 부모 질문의 답변이 조건값과 일치하는지 확인
+ return parentAnswer.answerValue === question.conditionalValue;
+ }
+
+ /**
+ * 부모 질문의 답변이 변경될 때 영향받는 자식 질문들의 답변 초기화
+ */
+ clearAffectedChildAnswers(
+ parentQuestionId: number,
+ newParentValue: string,
+ currentAnswers: Record<number, any>
+ ): Record<number, any> {
+ const updatedAnswers = { ...currentAnswers };
+ const childQuestionIds = this.parentChildMap.get(parentQuestionId) || [];
+
+ console.log(`🧹 질문 ${parentQuestionId}의 답변 변경으로 인한 자식 질문 정리:`, {
+ parentQuestionId,
+ newParentValue,
+ childQuestionIds,
+ 자식질문수: childQuestionIds.length
+ });
+
+ childQuestionIds.forEach(childId => {
+ const childQuestion = this.questions.find(q => q.id === childId);
+ if (!childQuestion) return;
+
+ console.log(`🔍 자식 질문 ${childId} 체크:`, {
+ childId,
+ questionNumber: childQuestion.questionNumber,
+ conditionalValue: childQuestion.conditionalValue,
+ newParentValue,
+ shouldKeep: childQuestion.conditionalValue === newParentValue,
+ currentAnswer: updatedAnswers[childId]?.answerValue
+ });
+
+ // 새로운 부모 값이 자식의 조건과 맞지 않으면 자식 답변 삭제
+ if (childQuestion.conditionalValue !== newParentValue) {
+ console.log(`🗑️ 자식 질문 Q${childQuestion.questionNumber} 답변 초기화 (조건 불일치)`);
+ delete updatedAnswers[childId];
+
+ // 재귀적으로 손자 질문들도 정리
+ const grandChildAnswers = this.clearAffectedChildAnswers(childId, '', updatedAnswers);
+ Object.assign(updatedAnswers, grandChildAnswers);
+ } else {
+ console.log(`✅ 자식 질문 Q${childQuestion.questionNumber} 유지 (조건 일치)`);
+ }
+ });
+
+ const clearedCount = childQuestionIds.filter(childId => !updatedAnswers[childId]).length;
+ const keptCount = childQuestionIds.filter(childId => !!updatedAnswers[childId]).length;
+
+ console.log(`📊 자식 질문 정리 완료:`, {
+ parentQuestionId,
+ 총자식질문: childQuestionIds.length,
+ 초기화된질문: clearedCount,
+ 유지된질문: keptCount
+ });
+
+ return updatedAnswers;
+ }
+
+ /**
+ * 표시되는 질문들 중 필수 질문의 완료 여부 체크
+ */
+ getRequiredQuestionsStatus(surveyAnswers: Record<number, any>): {
+ total: number;
+ completed: number;
+ completedQuestionIds: number[];
+ incompleteQuestionIds: number[];
+ } {
+ const status = this.getSimpleProgressStatus(surveyAnswers);
+ return {
+ total: status.totalRequired,
+ completed: status.completedRequired,
+ completedQuestionIds: status.completedQuestionIds,
+ incompleteQuestionIds: status.incompleteQuestionIds
+ };
+ }
+
+ /**
+ * 개별 질문의 완료 여부 체크
+ */
+ private isQuestionComplete(question: SurveyQuestion, surveyAnswers: Record<number, any>): boolean {
+ const answer = surveyAnswers[question.id];
+
+ // 🔍 상세한 완료 체크 로깅
+ const logData = {
+ questionId: question.id,
+ questionNumber: question.questionNumber,
+ questionText: question.questionText?.substring(0, 30) + '...',
+ isRequired: question.isRequired,
+ hasAnswer: !!answer,
+ answerValue: answer?.answerValue,
+ hasDetailText: question.hasDetailText,
+ hasFileUpload: question.hasFileUpload,
+ isConditional: !!question.parentQuestionId,
+ parentId: question.parentQuestionId,
+ conditionalValue: question.conditionalValue
+ };
+
+ console.log(`🔍 질문 완료 체크 [Q${question.questionNumber}]:`, logData);
+
+ if (!question.isRequired) {
+ console.log(`✅ Q${question.questionNumber}: 선택 질문이므로 완료`);
+ return true;
+ }
+
+ if (!answer?.answerValue) {
+ console.log(`❌ Q${question.questionNumber}: 답변이 없음`);
+ return false;
+ }
+
+ // 1. '기타' 선택 시 추가 입력이 필요한 경우
+ if (answer.answerValue === 'OTHER' && !answer.otherText?.trim()) {
+ console.log(`❌ Q${question.questionNumber}: '기타' 선택했지만 상세 내용 없음`);
+ return false;
+ }
+
+ // 2. 상세 텍스트가 필요한 경우
+ if (question.hasDetailText) {
+ const needsDetailText = ['YES', '네', 'Y', 'true', '1'].includes(answer.answerValue);
+
+ console.log(`📝 Q${question.questionNumber} 상세텍스트 체크:`, {
+ hasDetailText: question.hasDetailText,
+ answerValue: answer.answerValue,
+ needsDetailText,
+ detailText: answer.detailText?.length || 0,
+ detailTextExists: !!answer.detailText?.trim()
+ });
+
+ if (needsDetailText && !answer.detailText?.trim()) {
+ console.log(`❌ Q${question.questionNumber}: '${answer.answerValue}' 선택했지만 상세 내용 없음`);
+ return false;
+ }
+ }
+
+ // 3. 파일 업로드가 필요한 경우
+ if (question.hasFileUpload || question.questionType === 'FILE') {
+ const needsFileUpload = ['YES', '네', 'Y', 'true', '1'].includes(answer.answerValue);
+
+ console.log(`📁 Q${question.questionNumber} 파일업로드 체크:`, {
+ hasFileUpload: question.hasFileUpload,
+ questionType: question.questionType,
+ answerValue: answer.answerValue,
+ needsFileUpload,
+ filesCount: answer.files?.length || 0,
+ hasFiles: !!answer.files && answer.files.length > 0
+ });
+
+ if (needsFileUpload && (!answer.files || answer.files.length === 0)) {
+ console.log(`❌ Q${question.questionNumber}: '${answer.answerValue}' 선택했지만 파일 업로드 없음`);
+ return false;
+ }
+ }
+
+ // 4. ⭐ 핵심: 조건부 자식 질문들 체크
+ const childQuestions = this.getChildQuestions(question.id);
+
+ if (childQuestions.length > 0) {
+ console.log(`🔗 Q${question.questionNumber} 부모 질문 - 자식 질문들:`,
+ childQuestions.map(c => ({
+ id: c.id,
+ questionNumber: c.questionNumber,
+ condition: c.conditionalValue,
+ required: c.isRequired,
+ text: c.questionText?.substring(0, 20) + '...'
+ }))
+ );
+
+ // 현재 답변으로 트리거되는 자식 질문들 찾기
+ const triggeredChildren = childQuestions.filter(child =>
+ child.conditionalValue === answer.answerValue
+ );
+
+ console.log(`🎯 Q${question.questionNumber} 답변 '${answer.answerValue}'로 트리거된 자식들:`,
+ triggeredChildren.map(c => ({
+ id: c.id,
+ questionNumber: c.questionNumber,
+ required: c.isRequired,
+ text: c.questionText?.substring(0, 30) + '...'
+ }))
+ );
+
+ // 트리거된 필수 자식 질문들이 모두 완료되었는지 확인
+ for (const childQuestion of triggeredChildren) {
+ if (childQuestion.isRequired) {
+ console.log(`🔄 자식 질문 Q${childQuestion.questionNumber} 완료 체크 시작...`);
+ const childComplete = this.isQuestionComplete(childQuestion, surveyAnswers);
+ console.log(`📊 자식 질문 Q${childQuestion.questionNumber} 완료 상태: ${childComplete}`);
+
+ if (!childComplete) {
+ console.log(`❌ 부모 Q${question.questionNumber}의 자식 Q${childQuestion.questionNumber} 미완료`);
+ return false;
+ }
+ }
+ }
+
+ if (triggeredChildren.filter(c => c.isRequired).length > 0) {
+ console.log(`✅ Q${question.questionNumber}의 모든 필수 조건부 자식 질문들 완료됨`);
+ }
+ }
+
+ console.log(`✅ Q${question.questionNumber} 완료 체크 통과`);
+ return true;
+ }
+
+ /**
+ * 전체 설문조사 완료 여부
+ */
+ isSurveyComplete(surveyAnswers: Record<number, any>): boolean {
+ const status = this.getRequiredQuestionsStatus(surveyAnswers);
+ return status.total === status.completed;
+ }
+
+ /**
+ * 디버깅을 위한 관계 맵 출력
+ */
+ debugRelationships(): void {
+ console.log('=== 조건부 질문 관계 맵 ===');
+ console.log('부모 -> 자식:', Array.from(this.parentChildMap.entries()));
+ console.log('자식 -> 부모:', Array.from(this.childParentMap.entries()));
+ }
+
+ /**
+ * 특정 질문의 자식 질문들 가져오기
+ */
+ getChildQuestions(parentQuestionId: number): SurveyQuestion[] {
+ const childIds = this.parentChildMap.get(parentQuestionId) || [];
+ return this.questions.filter(q => childIds.includes(q.id));
+ }
+
+ /**
+ * 질문 트리 구조 생성 (중첩된 조건부 질문 처리)
+ */
+ buildQuestionTree(): QuestionNode[] {
+ const rootQuestions = this.questions.filter(q => !q.parentQuestionId);
+ return rootQuestions.map(q => this.buildQuestionNode(q));
+ }
+
+ private buildQuestionNode(question: SurveyQuestion): QuestionNode {
+ const childQuestions = this.getChildQuestions(question.id);
+ return {
+ question,
+ children: childQuestions.map(child => this.buildQuestionNode(child))
+ };
+ }
+}
+
+/**
+ * 질문 트리 노드 인터페이스
+ */
+export interface QuestionNode {
+ question: SurveyQuestion;
+ children: QuestionNode[];
+}
+
+/**
+ * React Hook으로 조건부 설문조사 상태 관리
+ */
+import React from 'react';
+
+export function useConditionalSurvey(template: SurveyTemplateWithQuestions | null) {
+ const [handler, setHandler] = React.useState<ConditionalSurveyHandler | null>(null);
+
+ React.useEffect(() => {
+ if (template) {
+ const newHandler = new ConditionalSurveyHandler(template);
+ setHandler(newHandler);
+
+ // 개발 환경에서 디버깅 정보 출력
+ if (process.env.NODE_ENV === 'development') {
+ newHandler.debugRelationships();
+ }
+ }
+ }, [template]);
+
+ return handler;
+} \ No newline at end of file
diff --git a/lib/basic-contract/viewer/basic-contract-sign-viewer.tsx b/lib/basic-contract/viewer/basic-contract-sign-viewer.tsx
index b92df089..fbf36738 100644
--- a/lib/basic-contract/viewer/basic-contract-sign-viewer.tsx
+++ b/lib/basic-contract/viewer/basic-contract-sign-viewer.tsx
@@ -1,11 +1,13 @@
"use client";
import React, {
-useState,
-useEffect,
-useRef,
-SetStateAction,
-Dispatch,
+ useState,
+ useEffect,
+ useRef,
+ SetStateAction,
+ Dispatch,
+ useMemo,
+ useCallback,
} from "react";
import { WebViewerInstance } from "@pdftron/webviewer";
import { Loader2, FileText, ClipboardList, AlertTriangle, FileSignature, Target, CheckCircle2 } from "lucide-react";
@@ -15,43 +17,55 @@ import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import {
-Dialog,
-DialogContent,
-DialogHeader,
-DialogTitle,
-DialogDescription,
-DialogFooter,
+ Dialog,
+ DialogContent,
+ DialogHeader,
+ DialogTitle,
+ DialogDescription,
+ DialogFooter,
} from "@/components/ui/dialog";
import { Label } from "@/components/ui/label";
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
-import { Checkbox } from "@/components/ui/checkbox";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea";
import { Upload } from "lucide-react";
-
-
+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";
interface FileInfo {
-path: string;
-name: string;
-type: 'main' | 'attachment' | 'survey';
+ path: string;
+ name: string;
+ type: 'main' | 'attachment' | 'survey';
}
interface BasicContractSignViewerProps {
-contractId?: number;
-filePath?: string;
-additionalFiles?: FileInfo[];
-templateName?: string;
-isOpen?: boolean;
-onClose?: () => void;
-onSign?: (documentData: ArrayBuffer, surveyData?: any) => Promise<void>;
-instance: WebViewerInstance | null;
-setInstance: Dispatch<SetStateAction<WebViewerInstance | null>>;
-t?: (key: string) => string;
+ contractId?: number;
+ filePath?: string;
+ additionalFiles?: FileInfo[];
+ templateName?: string;
+ isOpen?: boolean;
+ onClose?: () => void;
+ onSign?: (documentData: ArrayBuffer, surveyData?: any) => Promise<void>;
+ instance: WebViewerInstance | null;
+ setInstance: Dispatch<SetStateAction<WebViewerInstance | null>>;
+ onSurveyComplete?: () => void; // 🔥 새로 추가
+ onSignatureComplete?: () => void; // 🔥 새로 추가
+ t?: (key: string) => string;
+}
+
+// 폼 데이터 타입 정의
+interface SurveyFormData {
+ [key: string]: {
+ answerValue?: string;
+ detailText?: string;
+ otherText?: string;
+ files?: File[];
+ };
}
-// ✅ 자동 서명 필드 생성을 위한 타입 정의
+// 자동 서명 필드 생성을 위한 타입 정의
interface SignaturePattern {
regex: RegExp;
name: string;
@@ -62,22 +76,7 @@ interface SignaturePattern {
height?: number;
}
-interface DetectedSignatureLocation {
- pageIndex: number;
- text: string;
- rect: {
- x1: number;
- y1: number;
- x2: number;
- y2: number;
- };
- pattern: SignaturePattern;
- confidence: number;
-}
-
-// ✅ 개선된 자동 서명 필드 감지 클래스
-
-// ✅ 초간단 안전한 서명 필드 감지 클래스 (새로고침 제거)
+// 초간단 안전한 서명 필드 감지 클래스
class AutoSignatureFieldDetector {
private instance: WebViewerInstance;
private signaturePatterns: SignaturePattern[];
@@ -89,7 +88,6 @@ class AutoSignatureFieldDetector {
private initializePatterns(): SignaturePattern[] {
return [
- // 한국어 패턴들
{
regex: /서명\s*[::]\s*[_\-\s]{3,}/gi,
name: "한국어_서명_콜론",
@@ -108,7 +106,6 @@ class AutoSignatureFieldDetector {
width: 150,
height: 40
},
- // 영어 패턴들
{
regex: /signature\s*[::]\s*[_\-\s]{3,}/gi,
name: "영어_signature_콜론",
@@ -132,37 +129,29 @@ class AutoSignatureFieldDetector {
async detectAndCreateSignatureFields(): Promise<string[]> {
console.log("🔍 안전한 서명 필드 감지 시작...");
-
+
try {
- // ✅ 1단계: 기본 유효성 검사만
if (!this.instance?.Core?.documentViewer) {
throw new Error("WebViewer 인스턴스가 유효하지 않습니다.");
}
const { Core } = this.instance;
const { documentViewer } = Core;
-
- // ✅ 2단계: 문서 존재 확인만 (getPDFDoc 호출 안함)
+
const document = documentViewer.getDocument();
if (!document) {
throw new Error("PDF 문서가 로드되지 않았습니다.");
}
- console.log("📄 문서 확인 완료, 기존 필드 검사...");
-
-
- // ✅ 4단계: 단순 기본 서명 필드 생성 (텍스트 분석 스킵)
- console.log("📝 기본 서명 필드 생성...");
+ console.log("📄 문서 확인 완료, 기본 서명 필드 생성...");
const defaultField = await this.createSimpleSignatureField();
-
- // ✅ 5단계: 새로고침 없이 완료
- console.log("✅ 서명 필드 생성 완료 (새로고침 스킵)");
+
+ console.log("✅ 서명 필드 생성 완료");
return [defaultField];
} catch (error) {
- console.error("📛 안전한 서명 필드 생성 실패:", error);
-
- // 에러 타입별 메시지
+ console.error("📛 서명 필드 생성 실패:", error);
+
let errorMessage = "서명 필드 생성에 실패했습니다.";
if (error instanceof Error) {
if (error.message.includes("인스턴스")) {
@@ -171,35 +160,24 @@ class AutoSignatureFieldDetector {
errorMessage = "문서를 불러오는 중입니다.";
}
}
-
+
throw new Error(errorMessage);
}
}
-
-
- // ✅ 초간단 서명 필드 생성 (복잡한 텍스트 분석 없이)
private async createSimpleSignatureField(): Promise<string> {
try {
- const { Core, UI } = this.instance;
+ const { Core } = this.instance;
const { documentViewer, annotationManager, Annotations } = Core;
-
- // 페이지 정보 안전하게 가져오기
+
const pageCount = documentViewer.getPageCount();
- const lastPageIndex = Math.max(0, pageCount - 1);
-
- // 페이지 크기 안전하게 가져오기
const pageWidth = documentViewer.getPageWidth(pageCount) || 612;
const pageHeight = documentViewer.getPageHeight(pageCount) || 792;
-
+
console.log(`📏 페이지 정보: ${pageCount}페이지, 크기 ${pageWidth}x${pageHeight}`);
-
- // ✅ 간단한 서명 어노테이션 생성 (PDFDoc 접근 없이)
- const fieldName = `simple_signature_${Date.now()}`;
+ const fieldName = `simple_signature_${Date.now()}`;
const flags = new Annotations.WidgetFlags();
- // flags.set(Annotations.WidgetFlags.REQUIRED, true);
- // flags.set(Annotations.WidgetFlags.READ_ONLY, true);
const formField = new Core.Annotations.Forms.Field(
`SignatureFormField`,
@@ -208,89 +186,36 @@ class AutoSignatureFieldDetector {
flags,
}
);
-
- // 서명 위젯 어노테이션 생성
- const signatureWidget = new Annotations.SignatureWidgetAnnotation(formField,{
- // appearance: Annotations.SignatureWidgetAnnotation.DefaultAppearance.MATERIAL_OUTLINE,
+
+ const signatureWidget = new Annotations.SignatureWidgetAnnotation(formField, {
Width: 150,
Height: 50
});
-
- // 위치 설정 (마지막 페이지 하단)
+
signatureWidget.setPageNumber(pageCount);
signatureWidget.setX(pageWidth * 0.7);
signatureWidget.setY(pageHeight * 0.85);
signatureWidget.setWidth(150);
signatureWidget.setHeight(50);
-
- // 필드명 설정
- // signatureWidget.setFieldName(fieldName);
- // signatureWidget.setCustomData('fieldName', fieldName);
-
- // // 스타일 설정
- // signatureWidget.StrokeColor = new Annotations.Color(0, 100, 200); // 파란색
- // signatureWidget.StrokeThickness = 2;
-
- // 어노테이션 추가
+
annotationManager.addAnnotation(signatureWidget);
annotationManager.redrawAnnotation(signatureWidget);
-
- console.log(`✅ 간단 서명 필드 생성: ${fieldName}`);
+
+ console.log(`✅ 서명 필드 생성: ${fieldName}`);
return fieldName;
-
+
} catch (error) {
- console.error("📛 간단 서명 필드 생성 실패:", error);
-
- // ✅ 최후의 수단: 텍스트 어노테이션으로 안내
- // return await this.createTextGuidance();
+ console.error("📛 서명 필드 생성 실패:", error);
+ return "manual_signature_required";
}
}
-
- // ✅ 최후의 수단: 텍스트 안내 생성
- // private async createTextGuidance(): Promise<string> {
- // try {
- // const { Core } = this.instance;
- // const { documentViewer, annotationManager, Annotations } = Core;
-
- // const pageCount = documentViewer.getPageCount();
- // const pageWidth = documentViewer.getPageWidth(pageCount) || 612;
- // const pageHeight = documentViewer.getPageHeight(pageCount) || 792;
-
- // // 텍스트 어노테이션으로 서명 안내
- // const textAnnot = new Annotations.FreeTextAnnotation();
- // textAnnot.setPageNumber(pageCount);
- // textAnnot.setX(pageWidth * 0.25);
- // textAnnot.setY(pageHeight * 0.1);
- // textAnnot.setWidth(pageWidth * 0.5);
- // textAnnot.setHeight(60);
- // textAnnot.setContents("👆 여기를 클릭하여 서명해주세요");
- // textAnnot.FontSize = '14pt';
- // textAnnot.TextColor = new Annotations.Color(255, 0, 0); // 빨간색
- // textAnnot.StrokeColor = new Annotations.Color(255, 200, 200);
- // textAnnot.FillColor = new Annotations.Color(255, 240, 240);
-
- // const fieldName = `text_guidance_${Date.now()}`;
- // textAnnot.setCustomData('fieldName', fieldName);
-
- // annotationManager.addAnnotation(textAnnot);
- // annotationManager.redrawAnnotation(textAnnot);
-
- // console.log(`✅ 텍스트 안내 생성: ${fieldName}`);
- // return fieldName;
-
- // } catch (error) {
- // console.error("📛 텍스트 안내 생성도 실패:", error);
- // return "manual_signature_required";
- // }
- // }
}
function useAutoSignatureFields(instance: WebViewerInstance | null) {
const [signatureFields, setSignatureFields] = useState<string[]>([]);
const [isProcessing, setIsProcessing] = useState(false);
const [error, setError] = useState<string | null>(null);
-
- // 중복 실행 방지
+
const processingRef = useRef(false);
const timeoutRef = useRef<NodeJS.Timeout | null>(null);
@@ -298,65 +223,46 @@ function useAutoSignatureFields(instance: WebViewerInstance | null) {
if (!instance) return;
const { documentViewer } = instance.Core;
-
+
const handleDocumentLoaded = () => {
- // ✅ 중복 실행 방지
if (processingRef.current) {
console.log("📛 이미 처리 중이므로 스킵");
return;
}
- // ✅ 기존 타이머 정리
if (timeoutRef.current) {
clearTimeout(timeoutRef.current);
timeoutRef.current = null;
}
- // ✅ 짧은 지연 후 실행 (3초)
timeoutRef.current = setTimeout(async () => {
if (processingRef.current) return;
-
+
processingRef.current = true;
setIsProcessing(true);
setError(null);
-
+
try {
- console.log("📄 문서 로드 완료, 안전한 서명 필드 처리 시작...");
-
- // ✅ 최종 유효성 검사
+ console.log("📄 문서 로드 완료, 서명 필드 처리 시작...");
+
if (!instance?.Core?.documentViewer?.getDocument()) {
throw new Error("문서가 준비되지 않았습니다.");
}
const detector = new AutoSignatureFieldDetector(instance);
const fields = await detector.detectAndCreateSignatureFields();
-
+
setSignatureFields(fields);
-
- // ✅ 결과에 따른 토스트 메시지
+
if (fields.length > 0) {
const hasSimpleField = fields.some(field => field.startsWith('simple_signature_'));
- const hasTextGuidance = fields.some(field => field.startsWith('text_guidance_'));
- const hasManualRequired = fields.includes('manual_signature_required');
-
+
if (hasSimpleField) {
toast.success("📝 서명 필드가 생성되었습니다.", {
description: "마지막 페이지 하단의 파란색 영역에서 서명해주세요.",
icon: <FileSignature className="h-4 w-4 text-blue-500" />,
duration: 5000
});
- } else if (hasTextGuidance) {
- toast.success("📍 서명 안내가 표시되었습니다.", {
- description: "빨간색 텍스트 영역을 클릭하여 서명해주세요.",
- icon: <Target className="h-4 w-4 text-red-500" />,
- duration: 6000
- });
- } else if (hasManualRequired) {
- toast.info("수동 서명이 필요합니다.", {
- description: "문서에서 서명할 위치를 직접 클릭해주세요.",
- icon: <AlertTriangle className="h-4 w-4 text-amber-500" />,
- duration: 5000
- });
} else {
toast.success(`📋 ${fields.length}개의 서명 필드를 확인했습니다.`, {
description: "기존 서명 필드가 발견되었습니다.",
@@ -364,56 +270,40 @@ function useAutoSignatureFields(instance: WebViewerInstance | null) {
duration: 4000
});
}
- } else {
- toast.info("서명 필드 준비 중", {
- description: "문서에서 서명할 위치를 클릭해주세요.",
- icon: <FileSignature className="h-4 w-4 text-blue-500" />,
- duration: 4000
- });
}
-
+
} catch (error) {
- console.error("📛 안전한 서명 필드 처리 실패:", error);
-
+ console.error("📛 서명 필드 처리 실패:", error);
+
const errorMessage = error instanceof Error ? error.message : "서명 필드 처리에 실패했습니다.";
setError(errorMessage);
-
- // ✅ 부드러운 에러 처리
- if (errorMessage.includes("준비")) {
- toast.info("문서 로딩 중", {
- description: "잠시 후 다시 시도하거나 수동으로 서명해주세요.",
- icon: <Loader2 className="h-4 w-4 text-blue-500" />
- });
- } else {
- toast.info("수동 서명 모드", {
- description: "문서에서 서명할 위치를 직접 클릭해주세요.",
- icon: <FileSignature className="h-4 w-4 text-blue-500" />
- });
- }
+
+ toast.info("수동 서명 모드", {
+ description: "문서에서 서명할 위치를 직접 클릭해주세요.",
+ icon: <FileSignature className="h-4 w-4 text-blue-500" />
+ });
} finally {
setIsProcessing(false);
processingRef.current = false;
}
- }, 3000); // 3초 지연
+ }, 3000);
};
- // ✅ 이벤트 리스너 등록
documentViewer.removeEventListener('documentLoaded', handleDocumentLoaded);
documentViewer.addEventListener('documentLoaded', handleDocumentLoaded);
return () => {
documentViewer.removeEventListener('documentLoaded', handleDocumentLoaded);
-
+
if (timeoutRef.current) {
clearTimeout(timeoutRef.current);
timeoutRef.current = null;
}
-
+
processingRef.current = false;
};
}, [instance]);
- // ✅ 컴포넌트 언마운트 시 정리
useEffect(() => {
return () => {
if (timeoutRef.current) {
@@ -431,1083 +321,1574 @@ function useAutoSignatureFields(instance: WebViewerInstance | null) {
};
}
-export function BasicContractSignViewer({
-contractId,
-filePath,
-additionalFiles = [],
-templateName = "",
-isOpen = false,
-onClose,
-onSign,
-instance,
-setInstance,
-t = (key: string) => key,
-}: BasicContractSignViewerProps) {
+// 🔥 서명 감지를 위한 커스텀 훅 수정
+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);
- console.log("🔍 BasicContractSignViewer props:", {
- contractId,
- filePath,
- additionalFiles,
- templateName,
- isNDATemplate: templateName.includes('비밀유지') || templateName.includes('NDA')
- });
-
-const [fileLoading, setFileLoading] = useState<boolean>(true);
-const [activeTab, setActiveTab] = useState<string>("main");
-const [surveyData, setSurveyData] = useState<any>({});
-const [surveyAnswers, setSurveyAnswers] = useState<Record<number, any>>({});
-const [surveyTemplate, setSurveyTemplate] = useState<any>(null);
-const [surveyLoading, setSurveyLoading] = useState<boolean>(false);
-const [uploadedFiles, setUploadedFiles] = useState<Record<number, File[]>>({});
-const [isInitialLoaded, setIsInitialLoaded] = useState<boolean>(false);
-
-const viewer = useRef<HTMLDivElement>(null);
-const initialized = useRef(false);
-const isCancelled = useRef(false);
-const currentDocumentPath = useRef<string>("");
-const [showDialog, setShowDialog] = useState(isOpen);
-const webViewerInstance = useRef<WebViewerInstance | null>(null);
-
-// ✅ 자동 서명 필드 생성 훅 사용
-const { signatureFields, isProcessing: isAutoSignProcessing, hasSignatureFields, error: autoSignError } = useAutoSignatureFields(webViewerInstance.current || instance);
-
-// 템플릿 타입 판단
-const isComplianceTemplate = templateName.includes('준법');
-const isNDATemplate = templateName.includes('비밀유지') || templateName.includes('NDA');
-
-// 파일 목록 생성
-const allFiles: FileInfo[] = React.useMemo(() => {
- const files: FileInfo[] = [];
-
- if (filePath) {
- files.push({
- path: filePath,
- name: templateName || "기본 계약서",
- type: "main",
- });
- }
-
- const normalizedAttachments: FileInfo[] = (additionalFiles || [])
- .map((f: any, idx: number) => ({
- path: f.path ?? f.filePath ?? "",
- name: `첨부파일 ${idx + 1}`,
- type: "attachment" as const,
- }))
- .filter(f => !!f.path);
-
- files.push(...normalizedAttachments);
-
- if (isComplianceTemplate) {
- files.push({
- path: "",
- name: "준법 설문조사",
- type: "survey",
- });
- }
+ // 콜백 레퍼런스 업데이트
+ useEffect(() => {
+ onSignatureCompleteRef.current = onSignatureComplete;
+ }, [onSignatureComplete]);
- console.log("📂 생성된 allFiles:", files, { isNDATemplate, isComplianceTemplate });
- return files;
-}, [filePath, additionalFiles, templateName, isComplianceTemplate, isNDATemplate]);
+ const checkSignatureFields = useCallback(async () => {
+ if (!instance?.Core?.annotationManager) {
+ console.log('🔍 서명 체크: annotationManager 없음');
+ return false;
+ }
-// WebViewer 정리 함수
-const cleanupWebViewer = () => {
- console.log("🧹 WebViewer 정리 시작");
-
- if (webViewerInstance.current) {
try {
- const { documentViewer } = webViewerInstance.current.Core;
- if (documentViewer && documentViewer.getDocument()) {
- documentViewer.closeDocument();
+ const { annotationManager, documentViewer } = instance.Core;
+
+ // 문서가 로드되지 않았으면 false 반환
+ if (!documentViewer.getDocument()) {
+ console.log('🔍 서명 체크: 문서 미로드');
+ return false;
}
+
+ let hasSignature = false;
+
+ // 1. Form Fields 확인 (더 정확한 방법)
+ const fieldManager = annotationManager.getFieldManager();
+ const fields = fieldManager.getFields();
- if (webViewerInstance.current.UI && typeof webViewerInstance.current.UI.dispose === 'function') {
- webViewerInstance.current.UI.dispose();
+ 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;
+ }
+ }
}
+
+ // 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;
+ }
+ }
+ }
+ }
+
+ // 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;
+ }
+ }
+ }
+ }
+
+ console.log('🔍 최종 서명 감지 결과:', {
+ hasSignature,
+ fieldsCount: fields.length,
+ annotationsCount: annotationManager.getAnnotationsList().length
+ });
+
+ return hasSignature;
} catch (error) {
- console.warn("WebViewer 정리 중 에러 (무시됨):", error);
+ console.error('📛 서명 확인 중 에러:', error);
+ return false;
}
+ }, [instance]);
+
+ // 실시간 서명 감지 (무한 렌더링 방지)
+ useEffect(() => {
+ if (!instance?.Core) return;
+
+ 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 { documentViewer } = instance.Core;
- webViewerInstance.current = null;
- }
-
- if (instance && setInstance) {
- setInstance(null);
- }
-
- setTimeout(() => cleanupHtmlStyle(), 100);
-};
+ const handleDocumentLoaded = () => {
+ console.log('📄 문서 로드 완료, 서명 모니터링 준비');
+ // 문서 로드 후 3초 뒤에 모니터링 시작 (안정성 확보)
+ setTimeout(startMonitoring, 3000);
+ };
-// 다이얼로그 및 파일 상태 변경 시 리셋
-useEffect(() => {
- setShowDialog(isOpen);
-
- if (isOpen && isComplianceTemplate && !surveyTemplate) {
- loadSurveyTemplate();
- }
+ if (documentViewer?.getDocument()) {
+ // 이미 문서가 로드되어 있다면 바로 시작
+ setTimeout(startMonitoring, 1000);
+ } else {
+ // 문서 로드 대기
+ documentViewer?.addEventListener('documentLoaded', handleDocumentLoaded);
+ }
+
+ // 클리너 함수
+ return () => {
+ console.log('🧹 서명 모니터링 정리');
+ if (checkIntervalRef.current) {
+ clearInterval(checkIntervalRef.current);
+ checkIntervalRef.current = null;
+ }
+ documentViewer?.removeEventListener('documentLoaded', handleDocumentLoaded);
+ };
+ }, [instance]); // onSignatureComplete 제거하여 무한 렌더링 방지
+
+ // 수동 서명 확인 함수
+ const manualCheckSignature = useCallback(async () => {
+ console.log('🔍 수동 서명 확인 요청');
+ const hasSignature = await checkSignatureFields();
+ setHasValidSignature(hasSignature);
+ lastSignatureStateRef.current = hasSignature;
+ return hasSignature;
+ }, [checkSignatureFields]);
+
+ return {
+ hasValidSignature,
+ checkSignature: manualCheckSignature
+ };
+}
+
+export function BasicContractSignViewer({
+ contractId,
+ filePath,
+ additionalFiles = [],
+ templateName = "",
+ isOpen = false,
+ onClose,
+ onSign,
+ instance,
+ setInstance,
+ onSurveyComplete, // 🔥 추가
+ onSignatureComplete, // 🔥 추가
+ t = (key: string) => key,
+}: BasicContractSignViewerProps) {
+
+ const [fileLoading, setFileLoading] = useState<boolean>(true);
+ const [activeTab, setActiveTab] = useState<string>("main");
+ const [surveyData, setSurveyData] = useState<any>({});
+ 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 conditionalHandler = useConditionalSurvey(surveyTemplate);
+
+ const viewer = useRef<HTMLDivElement>(null);
+ const initialized = useRef(false);
+ const isCancelled = useRef(false);
+ const currentDocumentPath = useRef<string>("");
+ const [showDialog, setShowDialog] = useState(isOpen);
+ const webViewerInstance = useRef<WebViewerInstance | null>(null);
+
+ const { signatureFields, isProcessing: isAutoSignProcessing, hasSignatureFields, error: autoSignError } = useAutoSignatureFields(webViewerInstance.current || instance);
- if (isOpen) {
+ // 🔥 서명 감지 훅 사용
+ const { hasValidSignature } = useSignatureDetection(webViewerInstance.current || instance, onSignatureComplete);
+
+ const isComplianceTemplate = templateName.includes('준법');
+ const isNDATemplate = templateName.includes('비밀유지') || templateName.includes('NDA');
+
+ const allFiles: FileInfo[] = React.useMemo(() => {
+ const files: FileInfo[] = [];
+
+ if (filePath) {
+ files.push({
+ path: filePath,
+ name: templateName || "기본 계약서",
+ type: "main",
+ });
+ }
+
+ const normalizedAttachments: FileInfo[] = (additionalFiles || [])
+ .map((f: any, idx: number) => ({
+ path: f.path ?? f.filePath ?? "",
+ name: `첨부파일 ${idx + 1}`,
+ type: "attachment" as const,
+ }))
+ .filter(f => !!f.path);
+
+ files.push(...normalizedAttachments);
+
+ if (isComplianceTemplate) {
+ files.push({
+ path: "",
+ name: "준법 설문조사",
+ type: "survey",
+ });
+ }
+
+ return files;
+ }, [filePath, additionalFiles, templateName, isComplianceTemplate]);
+
+ const cleanupHtmlStyle = () => {
+ const elements = document.querySelectorAll('.Document_container');
+ elements.forEach((elem) => {
+ elem.remove();
+ });
+ };
+
+ const cleanupWebViewer = () => {
+ console.log("🧹 WebViewer 정리 시작");
+
+ if (webViewerInstance.current) {
+ try {
+ const { documentViewer } = webViewerInstance.current.Core;
+ if (documentViewer && documentViewer.getDocument()) {
+ documentViewer.closeDocument();
+ }
+
+ if (webViewerInstance.current.UI && typeof webViewerInstance.current.UI.dispose === 'function') {
+ webViewerInstance.current.UI.dispose();
+ }
+ } catch (error) {
+ console.warn("WebViewer 정리 중 에러 (무시됨):", error);
+ }
+
+ webViewerInstance.current = null;
+ }
+
+ if (instance && setInstance) {
+ setInstance(null);
+ }
+
+ setTimeout(() => cleanupHtmlStyle(), 100);
+ };
+
+ useEffect(() => {
+ setShowDialog(isOpen);
+
+ if (isOpen && isComplianceTemplate && !surveyTemplate) {
+ loadSurveyTemplate();
+ }
+
+ if (isOpen) {
+ setIsInitialLoaded(false);
+ currentDocumentPath.current = "";
+ }
+ }, [isOpen, isComplianceTemplate]);
+
+ useEffect(() => {
+ if (!filePath) return;
+
setIsInitialLoaded(false);
currentDocumentPath.current = "";
- console.log("🔄 새로운 계약서 열림, 상태 리셋");
- }
-}, [isOpen, isComplianceTemplate]);
+ setActiveTab("main");
-// filePath 변경 시 상태 리셋 및 즉시 문서 로드
-useEffect(() => {
- if (!filePath) return;
-
- console.log("🔄 filePath 변경으로 상태 리셋 및 문서 로드:", filePath);
-
- setIsInitialLoaded(false);
- currentDocumentPath.current = "";
- setActiveTab("main");
-
- const currentInstance = webViewerInstance.current || instance;
-
- if (currentInstance) {
- const normalizedPath = filePath.startsWith('/') ? filePath.substring(1) : filePath;
- const encodedPath = normalizedPath.split('/').map(part => encodeURIComponent(part)).join('/');
- const apiFilePath = `/api/files/${encodedPath}`;
-
- console.log("📄 filePath 변경으로 즉시 문서 로드:", apiFilePath);
-
- loadDocument(currentInstance, apiFilePath, true).then(() => {
- setIsInitialLoaded(true);
- console.log("✅ filePath 변경 문서 로드 완료");
- }).catch((error) => {
- console.error("📛 filePath 변경 문서 로드 실패:", error);
- });
- }
-}, [filePath, instance]);
+ const currentInstance = webViewerInstance.current || instance;
-const loadSurveyTemplate = async () => {
- setSurveyLoading(true);
-
- const mockTemplate = {
- id: 1,
- name: '기본 준법 설문조사',
- description: '모든 계약업체 대상 기본 준법 설문조사',
- questions: [
- {
- id: 4,
- questionNumber: '4',
- questionText: '귀사의 법률적 조직형태는?',
- questionType: 'DROPDOWN',
- isRequired: true,
- hasDetailText: false,
- hasFileUpload: false,
- options: [
- { id: 1, optionValue: 'COMPANY_CORP', optionText: '주식회사/유한회사' },
- { id: 2, optionValue: 'INDIVIDUAL', optionText: '개인회사' },
- { id: 3, optionValue: 'PARTNERSHIP', optionText: '조합' },
- { id: 4, optionValue: 'JOINT_VENTURE', optionText: '조인트벤처' },
- { id: 5, optionValue: 'OTHER', optionText: '기타', allowsOtherInput: true },
- ]
- },
- {
- id: 6,
- questionNumber: '6',
- questionText: '부패방지와 관련한 귀사의 준법정책이 있습니까? 있다면 첨부파일로 제공하여 주시기 바랍니다.',
- questionType: 'RADIO',
- isRequired: true,
- hasDetailText: false,
- hasFileUpload: true,
- options: [
- { id: 6, optionValue: 'YES', optionText: '네' },
- { id: 7, optionValue: 'NO', optionText: '아니오' },
- ]
- },
- {
- id: 11,
- questionNumber: '11',
- questionText: '귀사의 사주, 임원 중에서 전(최근 3년내)·현직 공직자인 사람이 있습니까? 만약 있다면 상세하게 기술해 주십시오.',
- questionType: 'RADIO',
- isRequired: true,
- hasDetailText: true,
- hasFileUpload: false,
- options: [
- { id: 11, optionValue: 'YES', optionText: '네' },
- { id: 12, optionValue: 'NO', optionText: '아니오' },
- ]
- },
- ]
+ if (currentInstance) {
+ const normalizedPath = filePath.startsWith('/') ? filePath.substring(1) : filePath;
+ const encodedPath = normalizedPath.split('/').map(part => encodeURIComponent(part)).join('/');
+ const apiFilePath = `/api/files/${encodedPath}`;
+
+ loadDocument(currentInstance, apiFilePath, true).then(() => {
+ setIsInitialLoaded(true);
+ }).catch((error) => {
+ console.error("📛 문서 로드 실패:", error);
+ });
+ }
+ }, [filePath, instance]);
+
+ const loadSurveyTemplate = async () => {
+ setSurveyLoading(true);
+
+ try {
+ const template = await getActiveSurveyTemplate();
+ setSurveyTemplate(template);
+ } catch (error) {
+ console.error('📛 설문조사 템플릿 로드 실패:', error);
+ setSurveyTemplate(null);
+ } finally {
+ setSurveyLoading(false);
+ }
};
-
- setSurveyTemplate(mockTemplate);
- setSurveyLoading(false);
-};
-
-// WebViewer 초기화 개선
-useEffect(() => {
- if (!initialized.current && viewer.current) {
- initialized.current = true;
- isCancelled.current = false;
-
- const initializeWebViewer = () => {
- if (!viewer.current || isCancelled.current) {
- console.log("📛 WebViewer 초기화 취소됨 (DOM 없음)");
- return;
- }
- const viewerElement = viewer.current;
-
- if (!viewerElement.isConnected) {
- console.log("📛 WebViewer DOM이 연결되지 않음, 재시도...");
- setTimeout(initializeWebViewer, 100);
- return;
- }
+ useEffect(() => {
+ if (!initialized.current && viewer.current) {
+ initialized.current = true;
+ isCancelled.current = false;
- cleanupWebViewer();
+ const initializeWebViewer = () => {
+ if (!viewer.current || isCancelled.current) {
+ return;
+ }
- console.log("📄 WebViewer 초기화 시작...");
-
- import("@pdftron/webviewer").then(({ default: WebViewer }) => {
- if (isCancelled.current || !viewer.current) {
- console.log("📛 WebViewer 초기화 취소됨 (import 후)");
+ const viewerElement = viewer.current;
+
+ if (!viewerElement.isConnected) {
+ setTimeout(initializeWebViewer, 100);
return;
}
- WebViewer(
- {
- path: "/pdftronWeb",
- licenseKey: process.env.NEXT_PUBLIC_PDFTRON_WEBVIEW_KEY,
- fullAPI: true ,
- disabledElements: [
-
- ]
- },
- viewerElement
- ).then((newInstance) => {
- if (isCancelled.current) {
- console.log("📛 WebViewer 인스턴스 생성 후 취소됨");
+ cleanupWebViewer();
+
+ import("@pdftron/webviewer").then(({ default: WebViewer }) => {
+ if (isCancelled.current || !viewer.current) {
return;
}
- console.log("📄 WebViewer 초기화 완료");
-
- webViewerInstance.current = newInstance;
- setInstance(newInstance);
- setFileLoading(false);
-
- const { documentViewer } = newInstance.Core;
- const FitMode = newInstance.UI.FitMode;
-
- // 문서 로드 완료 시 처리
- const handleDocumentLoaded = () => {
+ WebViewer(
+ {
+ path: "/pdftronWeb",
+ licenseKey: process.env.NEXT_PUBLIC_PDFTRON_WEBVIEW_KEY,
+ fullAPI: true,
+ },
+ viewerElement
+ ).then((newInstance) => {
+ if (isCancelled.current) {
+ return;
+ }
+
+ webViewerInstance.current = newInstance;
+ setInstance(newInstance);
setFileLoading(false);
- newInstance.UI.setFitMode(FitMode.FitWidth);
-
- requestAnimationFrame(() => {
- try {
- documentViewer.refreshAll();
- documentViewer.updateView();
- window.dispatchEvent(new Event("resize"));
- setTimeout(() => window.dispatchEvent(new Event("resize")), 100);
- } catch (e) {
- console.warn("layout refresh skipped", e);
+
+ const { documentViewer } = newInstance.Core;
+ const FitMode = newInstance.UI.FitMode;
+
+ const handleDocumentLoaded = () => {
+ setFileLoading(false);
+ newInstance.UI.setFitMode(FitMode.FitWidth);
+
+ requestAnimationFrame(() => {
+ try {
+ documentViewer.refreshAll();
+ documentViewer.updateView();
+ window.dispatchEvent(new Event("resize"));
+ setTimeout(() => window.dispatchEvent(new Event("resize")), 100);
+ } catch (e) {
+ console.warn("layout refresh skipped", e);
+ }
+ });
+ };
+
+ documentViewer.addEventListener('documentLoaded', handleDocumentLoaded);
+
+ newInstance.UI.setMinZoomLevel('25%');
+ newInstance.UI.setMaxZoomLevel('400%');
+
+ newInstance.UI.disableElements([
+ "toolbarGroup-Annotate",
+ "toolbarGroup-Shapes",
+ "toolbarGroup-Insert",
+ "toolbarGroup-Edit",
+ "toolbarGroup-FillAndSign",
+ "toolbarGroup-Forms",
+ "saveAsButton",
+ "downloadButton",
+ ]);
+
+ documentViewer.addEventListener('documentLoadingError', (error) => {
+ console.error("📛 문서 로딩 에러:", error);
+
+ let showToast = true;
+ let errorMessage = "문서를 불러오는데 실패했습니다.";
+
+ if (error && typeof error === 'object') {
+ const errorStr = JSON.stringify(error).toLowerCase();
+
+ if (errorStr.includes('linearized') || errorStr.includes('getreference')) {
+ showToast = false;
+ } else if (errorStr.includes('network')) {
+ errorMessage = "네트워크 연결을 확인해주세요.";
+ } else if (errorStr.includes('permission')) {
+ errorMessage = "문서에 접근할 권한이 없습니다.";
+ }
}
- });
- };
-
- documentViewer.addEventListener('documentLoaded', handleDocumentLoaded);
-
- documentViewer.addEventListener('layoutChanged', () => {
- if (newInstance.UI.getFitMode && newInstance.UI.getFitMode() !== FitMode.Zoom) {
- newInstance.UI.setFitMode(FitMode.Zoom);
- }
- });
-
- newInstance.UI.setMinZoomLevel('25%');
- newInstance.UI.setMaxZoomLevel('400%');
-
- newInstance.UI.disableElements([
- "toolbarGroup-Annotate",
- "toolbarGroup-Shapes",
- "toolbarGroup-Insert",
- "toolbarGroup-Edit",
- "toolbarGroup-FillAndSign",
- "toolbarGroup-Forms",
- "saveAsButton",
- "downloadButton",
-
- ])
-
- documentViewer.addEventListener('documentLoadingError', (error) => {
- console.error("📛 WebViewer 문서 로딩 에러:", error);
-
- let showToast = true;
- let errorMessage = "문서를 불러오는데 실패했습니다.";
-
- if (error && typeof error === 'object') {
- const errorStr = JSON.stringify(error).toLowerCase();
-
- if (errorStr.includes('linearized') || errorStr.includes('getreference')) {
- console.warn("⚠️ PDF 구조 경고 (문서 로드는 진행됨)");
- showToast = false;
- } else if (errorStr.includes('network')) {
- errorMessage = "네트워크 연결을 확인해주세요.";
- } else if (errorStr.includes('permission')) {
- errorMessage = "문서에 접근할 권한이 없습니다.";
+ if (showToast) {
+ setFileLoading(false);
+ toast.error(errorMessage);
}
- }
-
- if (showToast) {
- setFileLoading(false);
- toast.error(errorMessage);
- }
- });
+ });
+ }).catch((error) => {
+ console.error("📛 WebViewer 초기화 실패:", error);
+ setFileLoading(false);
+ toast.error("뷰어 초기화에 실패했습니다.");
+ });
}).catch((error) => {
- console.error("📛 WebViewer 초기화 실패:", error);
+ console.error("📛 WebViewer 모듈 로드 실패:", error);
setFileLoading(false);
- toast.error("뷰어 초기화에 실패했습니다.");
+ toast.error("뷰어 모듈을 불러오는데 실패했습니다.");
});
- }).catch((error) => {
- console.error("📛 WebViewer 모듈 로드 실패:", error);
- setFileLoading(false);
- toast.error("뷰어 모듈을 불러오는데 실패했습니다.");
+ };
+
+ requestAnimationFrame(() => {
+ setTimeout(initializeWebViewer, 50);
});
- };
+ }
- requestAnimationFrame(() => {
- setTimeout(initializeWebViewer, 50);
- });
- }
+ return () => {
+ isCancelled.current = true;
+ cleanupWebViewer();
+ };
+ }, [setInstance]);
- return () => {
- isCancelled.current = true;
- cleanupWebViewer();
+ const getExtFromPath = (p: string) => {
+ const m = p.toLowerCase().match(/\.([a-z0-9]+)(?:\?.*)?$/);
+ return m ? m[1] : undefined;
};
-}, [setInstance]);
-
-// 확장자 추출 유틸
-const getExtFromPath = (p: string) => {
- const m = p.toLowerCase().match(/\.([a-z0-9]+)(?:\?.*)?$/);
- return m ? m[1] : undefined;
-};
-
-// 문서 로드 함수 개선
-const loadDocument = async (
- instance: WebViewerInstance,
- documentPath: string,
- forceReload = false
-) => {
- if (!forceReload && currentDocumentPath.current === documentPath) {
- console.log("📄 동일한 문서이므로 스킵:", documentPath);
- return;
- }
-
- setFileLoading(true);
- try {
- console.log("📄 문서 로드 시작(UI):", documentPath, forceReload ? "(강제 리로드)" : "");
- if (!instance || !instance.UI || !instance.Core) {
- throw new Error("WebViewer 인스턴스가 유효하지 않습니다.");
+ const loadDocument = async (
+ instance: WebViewerInstance,
+ documentPath: string,
+ forceReload = false
+ ) => {
+ if (!forceReload && currentDocumentPath.current === documentPath) {
+ return;
}
- const ext = getExtFromPath(documentPath);
- await instance.UI.loadDocument(documentPath, {
- ...(ext ? { extension: ext } : {}),
- filename: documentPath.split("/").pop(),
- });
+ setFileLoading(true);
+ try {
+ if (!instance || !instance.UI || !instance.Core) {
+ throw new Error("WebViewer 인스턴스가 유효하지 않습니다.");
+ }
- currentDocumentPath.current = documentPath;
- console.log("📄 문서 로드 완료(UI):", documentPath);
+ const ext = getExtFromPath(documentPath);
+ await instance.UI.loadDocument(documentPath, {
+ ...(ext ? { extension: ext } : {}),
+ filename: documentPath.split("/").pop(),
+ });
- const { documentViewer } = instance.Core;
- requestAnimationFrame(() => {
- try {
- documentViewer.refreshAll();
- documentViewer.updateView();
- window.dispatchEvent(new Event("resize"));
- setTimeout(() => window.dispatchEvent(new Event("resize")), 100);
- } catch (e) {
- console.warn("레이아웃 새로고침 스킵:", e);
- }
- });
- } catch (error) {
- console.error("📛 문서 로딩 실패(UI):", error);
- currentDocumentPath.current = "";
-
- let msg = "문서를 불러오는데 실패했습니다.";
- if (error instanceof Error) {
- const s = error.message.toLowerCase();
- if (s.includes("network") || s.includes("fetch")) {
- msg = "네트워크 연결을 확인해주세요.";
- } else if (s.includes("permission") || s.includes("access")) {
- msg = "문서에 접근할 권한이 없습니다.";
- } else if (s.includes("corrupt") || s.includes("invalid")) {
- msg = "파일이 손상되었거나 형식이 올바르지 않습니다.";
- } else if (s.includes("linearized") || s.includes("getreference")) {
- msg = "";
+ currentDocumentPath.current = documentPath;
+
+ const { documentViewer } = instance.Core;
+ requestAnimationFrame(() => {
+ try {
+ documentViewer.refreshAll();
+ documentViewer.updateView();
+ window.dispatchEvent(new Event("resize"));
+ setTimeout(() => window.dispatchEvent(new Event("resize")), 100);
+ } catch (e) {
+ console.warn("레이아웃 새로고침 스킵:", e);
+ }
+ });
+ } catch (error) {
+ console.error("📛 문서 로딩 실패:", error);
+ currentDocumentPath.current = "";
+
+ let msg = "문서를 불러오는데 실패했습니다.";
+ if (error instanceof Error) {
+ const s = error.message.toLowerCase();
+ if (s.includes("network") || s.includes("fetch")) {
+ msg = "네트워크 연결을 확인해주세요.";
+ } else if (s.includes("permission") || s.includes("access")) {
+ msg = "문서에 접근할 권한이 없습니다.";
+ } else if (s.includes("corrupt") || s.includes("invalid")) {
+ msg = "파일이 손상되었거나 형식이 올바르지 않습니다.";
+ } else if (s.includes("linearized") || s.includes("getreference")) {
+ msg = "";
+ }
}
+ if (msg) toast.error(msg);
+ } finally {
+ setFileLoading(false);
}
- if (msg) toast.error(msg);
- } finally {
- setFileLoading(false);
- }
-};
-
-// 폼 데이터 수집 함수
-const collectFormData = async (instance: WebViewerInstance) => {
- try {
- const { documentViewer, annotationManager } = instance.Core;
- const fieldManager = annotationManager.getFieldManager();
- const fields = fieldManager.getFields();
-
- const formData: any = {};
- fields.forEach((field: any) => {
- formData[field.name] = field.value;
- });
-
- console.log('📝 폼 데이터 수집:', formData);
- return formData;
- } catch (error) {
- console.error('📛 폼 데이터 수집 실패:', error);
- return {};
- }
-};
+ };
-// 탭 변경 핸들러
-const handleTabChange = async (newTab: string) => {
- setActiveTab(newTab);
- if (newTab === "survey") return;
-
- const currentInstance = webViewerInstance.current || instance;
- if (!currentInstance || fileLoading) return;
-
- let targetFile: FileInfo | undefined;
- if (newTab === "main") {
- targetFile = allFiles.find(f => f.type === "main");
- } else if (newTab.startsWith("file-")) {
- const fileIndex = parseInt(newTab.replace("file-", ""), 10);
- targetFile = allFiles.filter(f => f.type !== 'survey')[fileIndex];
- }
+ const collectFormData = async (instance: WebViewerInstance) => {
+ try {
+ const { annotationManager } = instance.Core;
+ const fieldManager = annotationManager.getFieldManager();
+ const fields = fieldManager.getFields();
- if (!targetFile?.path) {
- console.warn("📛 대상 파일을 찾을 수 없음:", newTab, allFiles);
- return;
- }
+ const formData: any = {};
+ fields.forEach((field: any) => {
+ formData[field.name] = field.value;
+ });
- const normalizedPath = targetFile.path.startsWith("/")
- ? targetFile.path.substring(1)
- : targetFile.path;
- const encodedPath = normalizedPath.split("/").map(encodeURIComponent).join("/");
- const apiFilePath = `/api/files/${encodedPath}`;
+ return formData;
+ } catch (error) {
+ console.error('📛 폼 데이터 수집 실패:', error);
+ return {};
+ }
+ };
- console.log("📄 탭 변경으로 문서 로드:", { newTab, targetFile, apiFilePath });
+ const handleTabChange = async (newTab: string) => {
+ setActiveTab(newTab);
+ if (newTab === "survey") return;
- try {
- currentDocumentPath.current = "";
- await loadDocument(currentInstance, apiFilePath, true);
- setIsInitialLoaded(true);
+ const currentInstance = webViewerInstance.current || instance;
+ if (!currentInstance || fileLoading) return;
- const { documentViewer } = currentInstance.Core;
- requestAnimationFrame(() => {
- try {
- documentViewer.refreshAll();
- documentViewer.updateView();
- window.dispatchEvent(new Event("resize"));
- } catch (e) {
- console.warn("탭 변경 후 레이아웃 새로고침 스킵:", e);
- }
- });
- } catch (e) {
- console.error("📛 탭 변경 실패:", e);
- }
-};
-
-// 초기 메인 문서 로드 개선
-useEffect(() => {
- console.log("🔍 초기 로드 체크:", {
- hasInstance: !!(webViewerInstance.current || instance),
- hasFilePath: !!filePath,
- activeTab,
- isInitialLoaded,
- allFilesLength: allFiles.length,
- isNDATemplate
- });
-
- const currentInstance = webViewerInstance.current || instance;
-
- if (!currentInstance || !filePath || isInitialLoaded) {
- return;
- }
-
- const isMainTab = activeTab === 'main';
- const shouldLoadInitial = allFiles.length === 1 || isMainTab;
-
- if (!shouldLoadInitial || currentDocumentPath.current !== "") {
- return;
- }
-
- const normalizedPath = filePath.startsWith('/') ? filePath.substring(1) : filePath;
- const encodedPath = normalizedPath.split('/').map(part => encodeURIComponent(part)).join('/');
- const apiFilePath = `/api/files/${encodedPath}`;
-
- console.log("📄 초기 마운트 문서 로드:", { apiFilePath, isNDATemplate, activeTab });
-
- currentDocumentPath.current = "";
-
- loadDocument(currentInstance, apiFilePath, true).then(() => {
- setIsInitialLoaded(true);
- console.log("✅ 초기 마운트 로드 완료");
- }).catch((error) => {
- console.error("📛 초기 마운트 로드 실패:", error);
- });
-}, [webViewerInstance.current, instance, filePath, activeTab, isInitialLoaded, allFiles.length, isNDATemplate]);
-
-// 설문조사 답변 업데이트 함수
-const updateSurveyAnswer = (questionId: number, field: string, value: any) => {
- setSurveyAnswers(prev => ({
- ...prev,
- [questionId]: {
- ...prev[questionId],
- questionId,
- [field]: value
+ 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];
}
- }));
-};
-// 파일 업로드 핸들러
-const handleSurveyFileUpload = (questionId: number, files: FileList | null) => {
- if (!files) return;
-
- const fileArray = Array.from(files);
- setUploadedFiles(prev => ({
- ...prev,
- [questionId]: fileArray
- }));
-
- updateSurveyAnswer(questionId, 'files', fileArray);
-};
+ if (!targetFile?.path) {
+ console.warn("📛 대상 파일을 찾을 수 없음:", newTab, allFiles);
+ return;
+ }
-// 질문 완료 여부 체크
-const isSurveyQuestionComplete = (question: any): boolean => {
- const answer = surveyAnswers[question.id];
-
- if (!question.isRequired) return true;
- if (!answer?.answerValue) return false;
-
- if (question.hasDetailText && answer.answerValue === 'YES' && !answer.detailText) {
- return false;
- }
-
- if (question.hasFileUpload && answer.answerValue === 'YES' && (!answer.files || answer.files.length === 0)) {
- return false;
- }
-
- return true;
-};
-
-// 전체 설문조사 완료 여부 체크
-const isSurveyComplete = (): boolean => {
- if (!surveyTemplate?.questions) return false;
- return surveyTemplate.questions.every((question: any) => isSurveyQuestionComplete(question));
-};
-
-// 설문조사 데이터 처리
-const handleSurveyComplete = async () => {
- if (!isSurveyComplete()) {
- toast.error('모든 필수 항목을 완료해주세요.', {
- description: '미완성된 질문이 있습니다.',
- icon: <AlertTriangle className="h-5 w-5 text-red-500" />
- });
- return;
- }
+ const normalizedPath = targetFile.path.startsWith("/")
+ ? targetFile.path.substring(1)
+ : targetFile.path;
+ const encodedPath = normalizedPath.split("/").map(encodeURIComponent).join("/");
+ const apiFilePath = `/api/files/${encodedPath}`;
- try {
- console.log('설문조사 답변:', surveyAnswers);
-
- setSurveyData({
- completed: true,
- answers: Object.values(surveyAnswers),
- timestamp: new Date().toISOString()
- });
-
- toast.success("설문조사가 완료되었습니다!", {
- icon: <CheckCircle2 className="h-5 w-5 text-green-500" />
- });
- } catch (error) {
- console.error('설문조사 저장 실패:', error);
- toast.error('설문조사 저장에 실패했습니다.');
- }
-};
+ try {
+ currentDocumentPath.current = "";
+ await loadDocument(currentInstance, apiFilePath, true);
+ setIsInitialLoaded(true);
-// 서명 저장 핸들러
-const handleSave = async () => {
- const currentInstance = webViewerInstance.current || instance;
- if (!currentInstance) return;
-
- try {
- const { documentViewer, annotationManager } = currentInstance.Core;
- const doc = documentViewer.getDocument();
-
- if (!doc) {
- toast.error("문서가 로드되지 않았습니다.");
+ const { documentViewer } = currentInstance.Core;
+ requestAnimationFrame(() => {
+ try {
+ documentViewer.refreshAll();
+ documentViewer.updateView();
+ window.dispatchEvent(new Event("resize"));
+ } catch (e) {
+ console.warn("탭 변경 후 레이아웃 새로고침 스킵:", e);
+ }
+ });
+ } catch (e) {
+ console.error("📛 탭 변경 실패:", e);
+ }
+ };
+
+ useEffect(() => {
+ const currentInstance = webViewerInstance.current || instance;
+
+ if (!currentInstance || !filePath || isInitialLoaded) {
return;
}
-
- const formData = await collectFormData(currentInstance);
-
- const xfdfString = await annotationManager.exportAnnotations();
- const documentData = await doc.getFileData({
- xfdfString,
- downloadType: "pdf",
- });
-
- if (isComplianceTemplate && !surveyData.completed) {
- toast.error("준법 설문조사를 먼저 완료해주세요.");
- setActiveTab('survey');
+
+ const isMainTab = activeTab === 'main';
+ const shouldLoadInitial = allFiles.length === 1 || isMainTab;
+
+ if (!shouldLoadInitial || currentDocumentPath.current !== "") {
return;
}
-
- if (onSign) {
- await onSign(documentData, { formData, surveyData, signatureFields });
+
+ const normalizedPath = filePath.startsWith('/') ? filePath.substring(1) : filePath;
+ const encodedPath = normalizedPath.split('/').map(part => encodeURIComponent(part)).join('/');
+ const apiFilePath = `/api/files/${encodedPath}`;
+
+ currentDocumentPath.current = "";
+
+ loadDocument(currentInstance, apiFilePath, true).then(() => {
+ setIsInitialLoaded(true);
+ }).catch((error) => {
+ console.error("📛 초기 마운트 로드 실패:", error);
+ });
+ }, [webViewerInstance.current, instance, filePath, activeTab, isInitialLoaded, allFiles.length]);
+
+ const handleSave = async () => {
+ const currentInstance = webViewerInstance.current || instance;
+ if (!currentInstance) return;
+
+ try {
+ const { documentViewer, annotationManager } = currentInstance.Core;
+ const doc = documentViewer.getDocument();
+
+ if (!doc) {
+ toast.error("문서가 로드되지 않았습니다.");
+ return;
+ }
+
+ const formData = await collectFormData(currentInstance);
+
+ const xfdfString = await annotationManager.exportAnnotations();
+ const documentData = await doc.getFileData({
+ xfdfString,
+ downloadType: "pdf",
+ });
+
+ if (isComplianceTemplate && !surveyData.completed) {
+ toast.error("준법 설문조사를 먼저 완료해주세요.");
+ setActiveTab('survey');
+ return;
+ }
+
+ if (onSign) {
+ await onSign(documentData, { formData, surveyData, signatureFields });
+ } else {
+ toast.success("계약서가 성공적으로 서명되었습니다.");
+ }
+
+ handleClose();
+ } catch (error) {
+ console.error("📛 서명 저장 실패:", error);
+ toast.error("서명을 저장하는데 실패했습니다.");
+ }
+ };
+
+ const handleClose = () => {
+ if (onClose) {
+ onClose();
} else {
- toast.success("계약서가 성공적으로 서명되었습니다.");
+ setShowDialog(false);
}
-
- handleClose();
- } catch (error) {
- console.error("📛 서명 저장 실패:", error);
- toast.error("서명을 저장하는데 실패했습니다.");
- }
-};
-
-// 다이얼로그 닫기 핸들러
-const handleClose = () => {
- if (onClose) {
- onClose();
- } else {
- setShowDialog(false);
- }
-};
+ };
-// 동적 설문조사 컴포넌트
-const SurveyComponent = () => {
- 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>
- );
- }
+ // 개선된 SurveyComponent
+ const SurveyComponent = () => {
+ const {
+ control,
+ watch,
+ setValue,
+ getValues,
+ formState: { errors },
+ trigger,
+ } = useForm<SurveyFormData>({
+ defaultValues: {},
+ mode: 'onChange'
+ });
- if (!surveyTemplate) {
- return (
- <div className="h-full w-full">
- <Card className="h-full">
- <CardContent className="flex flex-col items-center justify-center h-full py-12">
- <AlertTriangle className="h-8 w-8 text-red-500 mb-4" />
- <p className="text-sm text-muted-foreground">설문조사 템플릿을 불러올 수 없습니다.</p>
- <Button
- variant="outline"
- onClick={loadSurveyTemplate}
- className="mt-2"
- >
- 다시 시도
- </Button>
- </CardContent>
- </Card>
- </div>
- );
- }
+ const 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: {}
+ };
+ }
- const completedCount = surveyTemplate.questions.filter((q: any) => isSurveyQuestionComplete(q)).length;
- const progressPercentage = surveyTemplate.questions.length > 0 ? (completedCount / surveyTemplate.questions.length) * 100 : 0;
+ 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
+ });
+ }
+ });
-const renderSurveyQuestion = (question: any) => {
- const answer = surveyAnswers[question.id];
- const isComplete = isSurveyQuestionComplete(question);
-
- return (
- <div key={question.id} className="mb-6 p-4 border rounded-lg bg-gray-50">
- <div className="flex items-start justify-between mb-3">
- <div className="flex-1">
- <Label className="text-sm font-medium text-gray-900 flex items-center">
- <span className="mr-2 px-2 py-1 bg-blue-100 text-blue-700 rounded text-xs">
- {question.questionNumber}
- </span>
- {question.questionText}
- {question.isRequired && <span className="text-red-500 ml-1">*</span>}
- </Label>
+ 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>
- {isComplete && (
- <CheckCircle2 className="h-5 w-5 text-green-500 ml-2" />
- )}
- </div>
+ );
+ }
- {question.questionType === 'RADIO' && (
- <RadioGroup
- value={answer?.answerValue || ''}
- onValueChange={(value) => updateSurveyAnswer(question.id, 'answerValue', value)}
- className="space-y-2"
- >
- {question.options?.map((option: any) => (
- <div key={option.id} className="flex items-center space-x-2">
- <RadioGroupItem value={option.optionValue} id={`${question.id}-${option.id}`} />
- <Label htmlFor={`${question.id}-${option.id}`} className="text-sm">
- {option.optionText}
- </Label>
- </div>
- ))}
- </RadioGroup>
- )}
-
- {question.questionType === 'DROPDOWN' && (
- <div className="space-y-2">
- <Select
- value={answer?.answerValue || ''}
- onValueChange={(value) => updateSurveyAnswer(question.id, 'answerValue', value)}
- >
- <SelectTrigger>
- <SelectValue placeholder="선택해주세요" />
- </SelectTrigger>
- <SelectContent>
- {question.options?.map((option: any) => (
- <SelectItem key={option.id} value={option.optionValue}>
- {option.optionText}
- </SelectItem>
- ))}
- </SelectContent>
- </Select>
-
- {answer?.answerValue === 'OTHER' && (
+ 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="기타 내용을 입력해주세요"
- value={answer?.otherText || ''}
- onChange={(e) => updateSurveyAnswer(question.id, 'otherText', e.target.value)}
className="mt-2"
/>
)}
- </div>
- )}
-
- {question.questionType === 'TEXTAREA' && (
- <Textarea
- placeholder="상세한 내용을 입력해주세요"
- value={answer?.detailText || ''}
- onChange={(e) => updateSurveyAnswer(question.id, 'detailText', e.target.value)}
- rows={4}
/>
- )}
-
- {question.hasDetailText && answer?.answerValue === 'YES' && (
- <div className="mt-3">
- <Label className="text-sm text-gray-700 mb-2 block">상세 내용을 기술해주세요:</Label>
- <Textarea
- placeholder="상세한 내용을 입력해주세요"
- value={answer?.detailText || ''}
- onChange={(e) => updateSurveyAnswer(question.id, 'detailText', e.target.value)}
- rows={3}
- className="w-full"
- />
- </div>
- )}
-
- {question.hasFileUpload && answer?.answerValue === 'YES' && (
- <div className="mt-3">
- <Label className="text-sm text-gray-700 mb-2 block">첨부파일:</Label>
- <div className="border-2 border-dashed border-gray-300 rounded-lg p-4">
- <input
- type="file"
- multiple
- onChange={(e) => handleSurveyFileUpload(question.id, e.target.files)}
- className="hidden"
- id={`file-${question.id}`}
- />
- <label htmlFor={`file-${question.id}`} className="cursor-pointer">
- <div className="flex flex-col items-center">
- <Upload className="h-8 w-8 text-gray-400 mb-2" />
- <span className="text-sm text-gray-500">파일을 선택하거나 여기에 드래그하세요</span>
+ );
+ };
+
+ 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>
- </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 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>
- )}
- </div>
- </div>
- )}
- </div>
- );
-};
- return (
- <div className="h-full w-full flex flex-col">
- <Card className="h-full flex flex-col">
- <CardHeader className="flex-shrink-0">
- <CardTitle className="flex items-center justify-between">
- <div className="flex items-center">
- <ClipboardList className="h-5 w-5 mr-2 text-amber-500" />
- {surveyTemplate.name}
- </div>
- <div className="text-sm text-gray-500">
- {completedCount}/{surveyTemplate.questions.length} 완료
+ {/* 세부 진행 상황 */}
+ {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>
- </CardTitle>
- <CardDescription>
- {surveyTemplate.description}
- </CardDescription>
-
- <div className="w-full bg-gray-200 rounded-full h-2">
- <div
- className="bg-blue-600 h-2 rounded-full transition-all duration-300"
- style={{ width: `${progressPercentage}%` }}
- />
- </div>
- </CardHeader>
-
- <CardContent className="flex-1 min-h-0 overflow-y-auto">
- <div className="space-y-6">
- <div className="p-4 border rounded-lg bg-yellow-50">
- <div className="flex items-start">
- <AlertTriangle className="h-5 w-5 text-yellow-600 mt-0.5 mr-2" />
- <div>
- <p className="font-medium text-yellow-800">중요 안내</p>
- <p className="text-sm text-yellow-700 mt-1">
- 본 설문조사는 준법 의무 확인을 위한 필수 절차입니다. 모든 항목을 정확히 작성해주세요.
- </p>
+ </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>
- <div className="space-y-4">
- {surveyTemplate.questions.map((question: any) => renderSurveyQuestion(question))}
- </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;
- <div className="flex justify-end pt-6 border-t">
- <Button
- onClick={handleSurveyComplete}
- disabled={!isSurveyComplete()}
- className="bg-blue-600 hover:bg-blue-700"
- >
- <CheckCircle2 className="h-4 w-4 mr-2" />
- 설문조사 완료
- </Button>
- </div>
- </div>
- </CardContent>
- </Card>
- </div>
- );
-};
-
-// 디버깅을 위한 useEffect
-useEffect(() => {
- if (isNDATemplate) {
- console.log("🔍 NDA 템플릿 디버깅:", {
- filePath,
- additionalFiles,
- allFiles,
- activeTab,
- isInitialLoaded,
- currentDocumentPath: currentDocumentPath.current,
- hasWebViewerInstance: !!webViewerInstance.current,
- hasParentInstance: !!instance,
- signatureFields,
- hasSignatureFields,
- isAutoSignProcessing,
- autoSignError
- });
- }
-}, [isNDATemplate, filePath, additionalFiles, allFiles, activeTab, isInitialLoaded, signatureFields, hasSignatureFields, isAutoSignProcessing, autoSignError]);
+ 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>
-// ✅ 서명 필드 상태 표시 컴포넌트
-const SignatureFieldsStatus = () => {
- if (!hasSignatureFields && !isAutoSignProcessing && !autoSignError) return null;
+ {/* 질문 타입별 렌더링 (기존 코드와 동일) */}
+ {/* 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>
+ )}
+ />
+ )}
- return (
- <div className="mb-2">
- {isAutoSignProcessing ? (
- <Badge variant="secondary" className="text-xs">
- <Loader2 className="h-3 w-3 mr-1 animate-spin" />
- 서명 필드 생성 중...
- </Badge>
- ) : autoSignError ? (
- <Badge variant="destructive" className="text-xs bg-red-50 text-red-700 border-red-200">
- <AlertTriangle className="h-3 w-3 mr-1" />
- 자동 생성 실패
- </Badge>
- ) : hasSignatureFields ? (
- <Badge variant="outline" className="text-xs bg-blue-50 text-blue-700 border-blue-200">
- <Target className="h-3 w-3 mr-1" />
- {signatureFields.length}개 서명 필드 자동 생성됨
- </Badge>
- ) : null}
- </div>
- );
-};
+ {/* 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>
+ )}
-// 인라인 뷰어 렌더링 부분 수정
-if (!isOpen && !onClose) {
- return (
- <div className="h-full w-full flex flex-col overflow-hidden">
- {allFiles.length > 1 ? (
- <Tabs value={activeTab} onValueChange={handleTabChange} className="h-full flex flex-col">
- <div className="border-b bg-gray-50 px-3 py-2 flex-shrink-0">
- <SignatureFieldsStatus />
- <TabsList className="grid w-full h-8" style={{ gridTemplateColumns: `repeat(${allFiles.length}, 1fr)` }}>
- {allFiles.map((file, index) => {
- let tabId: string;
- if (index === 0) {
- tabId = 'main';
- } else if (file.type === 'survey') {
- tabId = 'survey';
- } else {
- const fileOnlyIndex = allFiles.slice(0, index).filter(f => f.type !== 'survey').length;
- tabId = `file-${fileOnlyIndex}`;
- }
-
- return (
- <TabsTrigger key={tabId} value={tabId} className="text-xs">
- <div className="flex items-center space-x-1">
- {file.type === 'survey' ? (
- <ClipboardList className="h-3 w-3" />
- ) : (
- <FileText className="h-3 w-3" />
+ {/* TEXTAREA 타입 */}
+ {question.questionType === 'TEXTAREA' && (
+ <Controller
+ name={`${fieldName}.detailText`}
+ control={control}
+ rules={{ required: question.isRequired ? '필수 항목입니다.' : false }}
+ render={({ field }) => (
+ <Textarea
+ {...field}
+ placeholder="상세한 내용을 입력해주세요"
+ rows={4}
+ />
+ )}
+ />
)}
- <span className="truncate">{file.name}</span>
- {file.type === 'survey' && surveyData.completed && (
- <Badge variant="secondary" className="ml-1 h-4 px-1 text-xs">완료</Badge>
+
+ {/* 상세 텍스트 입력 */}
+ {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>
- </TabsTrigger>
- );
- })}
- </TabsList>
- </div>
-
- <div className="flex-1 min-h-0 overflow-hidden relative">
- <div
- className={`absolute inset-0 p-3 ${activeTab === 'survey' ? 'block' : 'hidden'}`}
- >
- <SurveyComponent />
- </div>
-
- <div
- className={`absolute inset-0 ${activeTab !== 'survey' ? 'block' : 'hidden'}`}
- >
- {/* ✅ 수정: 동일한 구조로 통일하고 스크롤 활성화 */}
- <div className="w-full h-full overflow-auto">
- <div
- ref={viewer}
- className="w-full h-full min-h-[400px]"
- style={{
- position: 'relative',
- // ✅ WebViewer가 스크롤을 제어하도록 설정
- overflow: 'visible'
- }}
- >
- {fileLoading && (
- <div className="absolute inset-0 flex flex-col items-center justify-center bg-white z-10">
- <Loader2 className="h-8 w-8 text-blue-500 animate-spin mb-4" />
- <p className="text-sm text-muted-foreground">문서 로딩 중...</p>
- </div>
- )}
- </div>
+ );
+ })}
</div>
- </div>
- </div>
- </Tabs>
- ) : (
- // ✅ 수정: Tabs가 없는 경우도 동일한 구조로 변경
- <div className="h-full w-full flex flex-col">
- <div className="flex-shrink-0 p-2">
- <SignatureFieldsStatus />
- </div>
- <div className="flex-1 min-h-0 overflow-hidden relative">
- <div className="absolute inset-0">
- <div className="w-full h-full overflow-auto">
- <div
- ref={viewer}
- className="w-full h-full min-h-[400px]"
- style={{
- position: 'relative',
- // ✅ WebViewer가 스크롤을 제어하도록 설정
- overflow: 'visible'
- }}
- >
- {fileLoading && (
- <div className="absolute inset-0 flex flex-col items-center justify-center bg-white z-10">
- <Loader2 className="h-8 w-8 text-blue-500 animate-spin mb-4" />
- <p className="text-sm text-muted-foreground">문서 로딩 중...</p>
- </div>
- )}
+
+ {/* ✅ 향상된 완료 버튼 */}
+ <div 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>
- </div>
- </div>
- </div>
- )}
- </div>
- );
-}
+ </form>
+ </CardContent>
+ </Card>
+ </div>
+ );
+ };
+
+ // 🔥 서명 상태 표시 컴포넌트 개선
+ const SignatureFieldsStatus = () => {
+ if (!hasSignatureFields && !isAutoSignProcessing && !autoSignError && !hasValidSignature) return null;
-// 다이얼로그 뷰어 렌더링 부분도 동일하게 수정
-return (
- <Dialog open={showDialog} onOpenChange={handleClose}>
- <DialogContent className="w-[90vw] max-w-6xl h-[90vh] flex flex-col p-0">
- <DialogHeader className="px-6 py-4 border-b flex-shrink-0">
- <DialogTitle className="flex items-center justify-between">
- <span>기본계약서 서명</span>
- <SignatureFieldsStatus />
- </DialogTitle>
- <DialogDescription>
- 계약서를 확인하고 서명을 진행해주세요.
- {isComplianceTemplate && (
- <span className="block mt-1 text-amber-600">📋 준법 설문조사를 먼저 완료해주세요.</span>
- )}
- {isNDATemplate && additionalFiles.length > 0 && (
- <span className="block mt-1 text-blue-600">📎 첨부서류 {additionalFiles.length}개를 각 탭에서 확인해주세요.</span>
- )}
- {hasSignatureFields && (
- <span className="block mt-1 text-green-600">
- 🎯 서명 위치가 자동으로 감지되었습니다.
- {signatureFields.some(f => f.includes('_text')) && (
- <span className="block text-sm text-amber-600">
- 💡 빨간색 텍스트로 표시된 영역을 찾아 서명해주세요.
- </span>
- )}
- {signatureFields.some(f => f.startsWith('default_signature_')) && !signatureFields.some(f => f.includes('_text')) && (
- <span className="block text-sm text-amber-600">
- 💡 마지막 페이지 하단의 핑크색 영역에서 서명해주세요.
- </span>
- )}
- </span>
- )}
- {autoSignError && (
- <span className="block mt-1 text-red-600">⚠️ 자동 서명 필드 생성 실패 - 수동으로 서명 위치를 클릭해주세요.</span>
- )}
- </DialogDescription>
- </DialogHeader>
+ return (
+ <div className="mb-2 flex items-center space-x-2">
+ {isAutoSignProcessing ? (
+ <Badge variant="secondary" className="text-xs">
+ <Loader2 className="h-3 w-3 mr-1 animate-spin" />
+ 서명 필드 생성 중...
+ </Badge>
+ ) : autoSignError ? (
+ <Badge variant="destructive" className="text-xs bg-red-50 text-red-700 border-red-200">
+ <AlertTriangle className="h-3 w-3 mr-1" />
+ 자동 생성 실패
+ </Badge>
+ ) : hasSignatureFields ? (
+ <Badge variant="outline" className="text-xs bg-blue-50 text-blue-700 border-blue-200">
+ <Target className="h-3 w-3 mr-1" />
+ {signatureFields.length}개 서명 필드 자동 생성됨
+ </Badge>
+ ) : null}
+
+ {/* 🔥 서명 완료 상태 표시 */}
+ {hasValidSignature && (
+ <Badge variant="outline" className="text-xs bg-green-50 text-green-700 border-green-200">
+ <CheckCircle2 className="h-3 w-3 mr-1" />
+ 서명 완료됨
+ </Badge>
+ )}
+ </div>
+ );
+ };
- <div className="flex-1 min-h-0 overflow-hidden">
+ // 인라인 뷰어 렌더링
+ if (!isOpen && !onClose) {
+ return (
+ <div className="h-full w-full flex flex-col overflow-hidden">
{allFiles.length > 1 ? (
<Tabs value={activeTab} onValueChange={handleTabChange} className="h-full flex flex-col">
<div className="border-b bg-gray-50 px-3 py-2 flex-shrink-0">
+ <SignatureFieldsStatus />
<TabsList className="grid w-full h-8" style={{ gridTemplateColumns: `repeat(${allFiles.length}, 1fr)` }}>
{allFiles.map((file, index) => {
- 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 {
+ const fileOnlyIndex = allFiles.slice(0, index).filter(f => f.type !== 'survey').length;
+ tabId = `file-${fileOnlyIndex}`;
+ }
+
return (
<TabsTrigger key={tabId} value={tabId} className="text-xs">
<div className="flex items-center space-x-1">
- {file.type === 'survey' ? <ClipboardList className="h-3 w-3" /> : <FileText className="h-3 w-3" />}
+ {file.type === 'survey' ? (
+ <ClipboardList className="h-3 w-3" />
+ ) : (
+ <FileText className="h-3 w-3" />
+ )}
<span className="truncate">{file.name}</span>
{file.type === 'survey' && surveyData.completed && (
<Badge variant="secondary" className="ml-1 h-4 px-1 text-xs">완료</Badge>
@@ -1520,17 +1901,20 @@ return (
</div>
<div className="flex-1 min-h-0 overflow-hidden relative">
- <div className={`absolute inset-0 p-3 ${activeTab === 'survey' ? 'block' : 'hidden'}`}>
+ <div
+ className={`absolute inset-0 p-3 ${activeTab === 'survey' ? 'block' : 'hidden'}`}
+ >
<SurveyComponent />
</div>
- <div className={`absolute inset-0 ${activeTab !== 'survey' ? 'block' : 'hidden'}`}>
- {/* ✅ 수정: 스크롤 활성화 */}
+ <div
+ className={`absolute inset-0 ${activeTab !== 'survey' ? 'block' : 'hidden'}`}
+ >
<div className="w-full h-full overflow-auto">
- <div
- ref={viewer}
+ <div
+ ref={viewer}
className="w-full h-full min-h-[400px]"
- style={{
+ style={{
position: 'relative',
overflow: 'visible'
}}
@@ -1547,15 +1931,17 @@ return (
</div>
</Tabs>
) : (
- // ✅ 수정: 다이얼로그에서 뷰어만 있는 경우도 동일한 구조
- <div className="h-full flex flex-col">
+ <div className="h-full w-full flex flex-col">
+ <div className="flex-shrink-0 p-2">
+ <SignatureFieldsStatus />
+ </div>
<div className="flex-1 min-h-0 overflow-hidden relative">
<div className="absolute inset-0">
<div className="w-full h-full overflow-auto">
- <div
- ref={viewer}
+ <div
+ ref={viewer}
className="w-full h-full min-h-[400px]"
- style={{
+ style={{
position: 'relative',
overflow: 'visible'
}}
@@ -1573,133 +1959,120 @@ return (
</div>
)}
</div>
+ );
+ }
- <DialogFooter className="px-6 py-4 border-t bg-white flex-shrink-0">
- <Button variant="outline" onClick={handleClose} disabled={fileLoading}>취소</Button>
- <Button onClick={handleSave} disabled={fileLoading || isAutoSignProcessing}>
- <FileSignature className="h-4 w-4 mr-2" />
- 서명 완료
- </Button>
- </DialogFooter>
- </DialogContent>
- </Dialog>
-);
-
-// 다이얼로그 뷰어 렌더링
-return (
- <Dialog open={showDialog} onOpenChange={handleClose}>
- <DialogContent className="w-[90vw] max-w-6xl h-[90vh] flex flex-col p-0">
- <DialogHeader className="px-6 py-4 border-b flex-shrink-0">
- <DialogTitle className="flex items-center justify-between">
- <span>기본계약서 서명</span>
- <SignatureFieldsStatus />
- </DialogTitle>
- <DialogDescription>
- 계약서를 확인하고 서명을 진행해주세요.
- {isComplianceTemplate && (
- <span className="block mt-1 text-amber-600">📋 준법 설문조사를 먼저 완료해주세요.</span>
- )}
- {isNDATemplate && additionalFiles.length > 0 && (
- <span className="block mt-1 text-blue-600">📎 첨부서류 {additionalFiles.length}개를 각 탭에서 확인해주세요.</span>
- )}
- {hasSignatureFields && (
- <span className="block mt-1 text-green-600">
- 🎯 서명 위치가 자동으로 감지되었습니다.
- {signatureFields.some(f => f.includes('_text')) && (
- <span className="block text-sm text-amber-600">
- 💡 빨간색 텍스트로 표시된 영역을 찾아 서명해주세요.
- </span>
- )}
- {signatureFields.some(f => f.startsWith('default_signature_')) && !signatureFields.some(f => f.includes('_text')) && (
- <span className="block text-sm text-amber-600">
- 💡 마지막 페이지 하단의 핑크색 영역에서 서명해주세요.
- </span>
- )}
- </span>
- )}
- {autoSignError && (
- <span className="block mt-1 text-red-600">⚠️ 자동 서명 필드 생성 실패 - 수동으로 서명 위치를 클릭해주세요.</span>
- )}
- </DialogDescription>
- </DialogHeader>
-
- <div className="flex-1 min-h-0 overflow-hidden">
- {allFiles.length > 1 ? (
- <Tabs value={activeTab} onValueChange={handleTabChange} className="h-full flex flex-col">
- <div className="border-b bg-gray-50 px-3 py-2 flex-shrink-0">
- <TabsList className="grid w-full h-8" style={{ gridTemplateColumns: `repeat(${allFiles.length}, 1fr)` }}>
- {allFiles.map((file, index) => {
- const tabId = index === 0 ? 'main' : file.type === 'survey' ? 'survey' : `file-${index}`;
- return (
- <TabsTrigger key={tabId} value={tabId} className="text-xs">
- <div className="flex items-center space-x-1">
- {file.type === 'survey' ? <ClipboardList className="h-3 w-3" /> : <FileText className="h-3 w-3" />}
- <span className="truncate">{file.name}</span>
- {file.type === 'survey' && surveyData.completed && (
- <Badge variant="secondary" className="ml-1 h-4 px-1 text-xs">완료</Badge>
- )}
- </div>
- </TabsTrigger>
- );
- })}
- </TabsList>
- </div>
-
- <div className="flex-1 min-h-0 overflow-hidden relative">
- <div className={`absolute inset-0 p-3 ${activeTab === 'survey' ? 'block' : 'hidden'}`}>
- <SurveyComponent />
+ // 다이얼로그 뷰어 렌더링
+ return (
+ <Dialog open={showDialog} onOpenChange={handleClose}>
+ <DialogContent className="w-[90vw] max-w-6xl h-[90vh] flex flex-col p-0">
+ <DialogHeader className="px-6 py-4 border-b flex-shrink-0">
+ <DialogTitle className="flex items-center justify-between">
+ <span>기본계약서 서명</span>
+ <SignatureFieldsStatus />
+ </DialogTitle>
+ <DialogDescription>
+ 계약서를 확인하고 서명을 진행해주세요.
+ {isComplianceTemplate && (
+ <span className="block mt-1 text-amber-600">📋 준법 설문조사를 먼저 완료해주세요.</span>
+ )}
+ {hasSignatureFields && (
+ <span className="block mt-1 text-green-600">
+ 🎯 서명 위치가 자동으로 감지되었습니다.
+ </span>
+ )}
+ {/* 🔥 서명 완료 상태 안내 */}
+ {hasValidSignature && (
+ <span className="block mt-1 text-green-600">
+ ✅ 서명이 완료되었습니다.
+ </span>
+ )}
+ </DialogDescription>
+ </DialogHeader>
+
+ <div className="flex-1 min-h-0 overflow-hidden">
+ {allFiles.length > 1 ? (
+ <Tabs value={activeTab} onValueChange={handleTabChange} className="h-full flex flex-col">
+ <div className="border-b bg-gray-50 px-3 py-2 flex-shrink-0">
+ <TabsList className="grid w-full h-8" style={{ gridTemplateColumns: `repeat(${allFiles.length}, 1fr)` }}>
+ {allFiles.map((file, index) => {
+ const tabId = index === 0 ? 'main' : file.type === 'survey' ? 'survey' : `file-${index}`;
+ return (
+ <TabsTrigger key={tabId} value={tabId} className="text-xs">
+ <div className="flex items-center space-x-1">
+ {file.type === 'survey' ? <ClipboardList className="h-3 w-3" /> : <FileText className="h-3 w-3" />}
+ <span className="truncate">{file.name}</span>
+ {file.type === 'survey' && surveyData.completed && (
+ <Badge variant="secondary" className="ml-1 h-4 px-1 text-xs">완료</Badge>
+ )}
+ </div>
+ </TabsTrigger>
+ );
+ })}
+ </TabsList>
</div>
- <div className={`absolute inset-0 ${activeTab !== 'survey' ? 'block' : 'hidden'}`}>
- <div
- ref={viewer}
- className="w-full h-full"
- style={{ position: 'relative', minHeight: '400px' }}
- >
- {fileLoading && (
- <div className="absolute inset-0 flex flex-col items-center justify-center bg-white z-10">
- <Loader2 className="h-8 w-8 text-blue-500 animate-spin mb-4" />
- <p className="text-sm text-muted-foreground">문서 로딩 중...</p>
+ <div className="flex-1 min-h-0 overflow-hidden relative">
+ <div className={`absolute inset-0 p-3 ${activeTab === 'survey' ? 'block' : 'hidden'}`}>
+ <SurveyComponent />
+ </div>
+
+ <div className={`absolute inset-0 ${activeTab !== 'survey' ? 'block' : 'hidden'}`}>
+ <div className="w-full h-full overflow-auto">
+ <div
+ ref={viewer}
+ className="w-full h-full min-h-[400px]"
+ style={{
+ position: 'relative',
+ overflow: 'visible'
+ }}
+ >
+ {fileLoading && (
+ <div className="absolute inset-0 flex flex-col items-center justify-center bg-white z-10">
+ <Loader2 className="h-8 w-8 text-blue-500 animate-spin mb-4" />
+ <p className="text-sm text-muted-foreground">문서 로딩 중...</p>
+ </div>
+ )}
</div>
- )}
+ </div>
</div>
</div>
- </div>
- </Tabs>
- ) : (
- <div className="h-full relative">
- <div
- ref={viewer}
- className="absolute inset-0"
- style={{ position: 'relative', minHeight: '400px' }}
- >
- {fileLoading && (
- <div className="absolute inset-0 flex flex-col items-center justify-center bg-white z-10">
- <Loader2 className="h-8 w-8 text-blue-500 animate-spin mb-4" />
- <p className="text-sm text-muted-foreground">문서 로딩 중...</p>
+ </Tabs>
+ ) : (
+ <div className="h-full flex flex-col">
+ <div className="flex-1 min-h-0 overflow-hidden relative">
+ <div className="absolute inset-0">
+ <div className="w-full h-full overflow-auto">
+ <div
+ ref={viewer}
+ className="w-full h-full min-h-[400px]"
+ style={{
+ position: 'relative',
+ overflow: 'visible'
+ }}
+ >
+ {fileLoading && (
+ <div className="absolute inset-0 flex flex-col items-center justify-center bg-white z-10">
+ <Loader2 className="h-8 w-8 text-blue-500 animate-spin mb-4" />
+ <p className="text-sm text-muted-foreground">문서 로딩 중...</p>
+ </div>
+ )}
+ </div>
+ </div>
</div>
- )}
+ </div>
</div>
- </div>
- )}
- </div>
-
- <DialogFooter className="px-6 py-4 border-t bg-white flex-shrink-0">
- <Button variant="outline" onClick={handleClose} disabled={fileLoading}>취소</Button>
- <Button onClick={handleSave} disabled={fileLoading || isAutoSignProcessing}>
- <FileSignature className="h-4 w-4 mr-2" />
- 서명 완료
- </Button>
- </DialogFooter>
- </DialogContent>
- </Dialog>
-);
-}
+ )}
+ </div>
-// WebViewer 정리 함수
-const cleanupHtmlStyle = () => {
-const elements = document.querySelectorAll('.Document_container');
-elements.forEach((elem) => {
- elem.remove();
-});
-}; \ No newline at end of file
+ <DialogFooter className="px-6 py-4 border-t bg-white flex-shrink-0">
+ <Button variant="outline" onClick={handleClose} disabled={fileLoading}>취소</Button>
+ <Button onClick={handleSave} disabled={fileLoading || isAutoSignProcessing}>
+ <FileSignature className="h-4 w-4 mr-2" />
+ 서명 완료
+ </Button>
+ </DialogFooter>
+ </DialogContent>
+ </Dialog>
+ );
+} \ No newline at end of file