// lib/utils/survey-conditional.ts import type { SurveyQuestion, SurveyTemplateWithQuestions } from '../service'; /** * 조건부 질문 처리를 위한 유틸리티 클래스 */ export class ConditionalSurveyHandler { private questions: SurveyQuestion[]; private parentChildMap: Map; private childParentMap: Map; 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): 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): 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 isValidFile(file: any): boolean { if (!file || typeof file !== 'object') return false; // 브라우저 File 객체 체크 (새로 업로드한 파일) if (file.name || file.size || file.type) return true; // 서버 파일 메타데이터 체크 (기존 파일) if (file.filename || file.originalName || file.id || file.mimeType) return true; return false; } private hasValidFiles(files: any[]): boolean { return files && files.length > 0 && files.every(file => this.isValidFile(file)); } private isQuestionCompleteEnhanced(question: SurveyQuestion, surveyAnswers: Record): 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 체크 (브라우저 File 객체와 서버 파일 메타데이터 모두 처리) - 수정된 부분 const hasValidFilesResult = this.hasValidFiles(answer.files || []); console.log(`📊 Q${question.questionNumber} 완료 조건 체크:`, { hasAnswerValue, hasDetailText, hasOtherText, hasValidFiles: hasValidFilesResult, questionType: question.questionType, hasDetailTextRequired: question.hasDetailText, hasFileUploadRequired: question.hasFileUpload || question.questionType === 'FILE' }); // 질문 타입별 완료 조건은 동일하지만 hasValidFilesResult 변수 사용 switch (question.questionType) { case 'RADIO': case 'DROPDOWN': const isSelectComplete = hasAnswerValue && hasOtherText; if (question.hasDetailText && isSelectComplete) { return hasDetailText; } if ((question.hasFileUpload || question.questionType === 'FILE') && isSelectComplete) { return hasValidFilesResult; // 수정된 부분 } return isSelectComplete; case 'TEXTAREA': return hasDetailText || hasAnswerValue; case 'FILE': return hasValidFilesResult; // 수정된 부분 default: let isComplete = hasAnswerValue || hasDetailText || hasValidFilesResult; // 수정된 부분 if (question.hasDetailText) { isComplete = isComplete && hasDetailText; } if (question.hasFileUpload) { isComplete = isComplete && hasValidFilesResult; // 수정된 부분 } return isComplete; } } // 🎯 개선된 미완료 이유 제공 함수 private getIncompleteReasonEnhanced(question: SurveyQuestion, surveyAnswers: Record): 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): { visibleQuestions: SurveyQuestion[]; totalRequired: number; completedRequired: number; completedQuestionIds: number[]; incompleteQuestionIds: number[]; progressPercentage: number; debugInfo?: any; } { // 🎯 현재 답변 상태에 따라 표시되는 질문들 계산 const visibleQuestions = this.getVisibleQuestions(surveyAnswers); // 🚨 중요: 트리거된 조건부 질문들을 필수로 처리 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; }); 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); // 디버깅 정보 수집 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; // 🔍 미완료 질문들의 구체적 이유 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); } return result; } /** * 전체 설문조사의 진행 상태를 계산 (조건부 질문 고려) * 표시되지 않는 질문들은 자동으로 완료된 것으로 간주하여 프로그레스가 역행하지 않도록 함 */ getOverallProgressStatus(surveyAnswers: Record): { 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): { 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): SurveyQuestion[] { return this.questions.filter(question => this.shouldShowQuestion(question, surveyAnswers)); } /** * 특정 질문이 현재 표시되어야 하는지 판단 */ private shouldShowQuestion(question: SurveyQuestion, surveyAnswers: Record): 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 ): Record { 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; // 새로운 부모 값이 자식의 조건과 맞지 않으면 자식 답변 삭제 if (childQuestion.conditionalValue !== newParentValue) { delete updatedAnswers[childId]; // 재귀적으로 손자 질문들도 정리 const grandChildAnswers = this.clearAffectedChildAnswers(childId, '', updatedAnswers); Object.assign(updatedAnswers, grandChildAnswers); } else { } }); const clearedCount = childQuestionIds.filter(childId => !updatedAnswers[childId]).length; const keptCount = childQuestionIds.filter(childId => !!updatedAnswers[childId]).length; return updatedAnswers; } /** * 표시되는 질문들 중 필수 질문의 완료 여부 체크 */ getRequiredQuestionsStatus(surveyAnswers: Record): { 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): 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 }; if (!question.isRequired) { return true; } if (!answer?.answerValue) { return false; } // 1. '기타' 선택 시 추가 입력이 필요한 경우 if (answer.answerValue === 'OTHER' && !answer.otherText?.trim()) { return false; } // 2. 상세 텍스트가 필요한 경우 if (question.hasDetailText) { const needsDetailText = ['YES', '네', 'Y', 'true', '1'].includes(answer.answerValue); if (needsDetailText && !answer.detailText?.trim()) { 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)) { return false; } } // 4. ⭐ 핵심: 조건부 자식 질문들 체크 const childQuestions = this.getChildQuestions(question.id); if (childQuestions.length > 0) { // 현재 답변으로 트리거되는 자식 질문들 찾기 const triggeredChildren = childQuestions.filter(child => child.conditionalValue === answer.answerValue ); // 트리거된 필수 자식 질문들이 모두 완료되었는지 확인 for (const childQuestion of triggeredChildren) { if (childQuestion.isRequired) { const childComplete = this.isQuestionComplete(childQuestion, surveyAnswers); if (!childComplete) { return false; } } } if (triggeredChildren.filter(c => c.isRequired).length > 0) { } } return true; } /** * 전체 설문조사 완료 여부 */ isSurveyComplete(surveyAnswers: Record): 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(null); React.useEffect(() => { if (template) { const newHandler = new ConditionalSurveyHandler(template); setHandler(newHandler); // 개발 환경에서 디버깅 정보 출력 if (process.env.NODE_ENV === 'development') { newHandler.debugRelationships(); } } }, [template]); return handler; }