From 12e936c0b45ffa1c8f3c02ff77961212767be9a7 Mon Sep 17 00:00:00 2001 From: dujinkim Date: Tue, 26 Aug 2025 01:17:56 +0000 Subject: (대표님) 가입, 기본계약, 벤더 (최겸) 기술영업 아이템 관련 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../vendor-table/basic-contract-sign-dialog.tsx | 561 +++++++++++--- .../vendor-table/basic-contract-table.tsx | 1 - .../vendor-table/survey-conditional.ts | 860 +++++++++++++++++++++ 3 files changed, 1310 insertions(+), 112 deletions(-) create mode 100644 lib/basic-contract/vendor-table/survey-conditional.ts (limited to 'lib/basic-contract/vendor-table') 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([]); const [isLoadingAttachments, setIsLoadingAttachments] = React.useState(false); + // 계약서 상태 관리 + const [contractStatuses, setContractStatuses] = React.useState([]); + + // 🔥 새로 추가: 서명/설문 완료 상태 관리 + const [surveyCompletionStatus, setSurveyCompletionStatus] = React.useState>({}); + const [signatureStatus, setSignatureStatus] = React.useState>({}); + 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: + }); + return; + } + + if (!signatureCompleted) { + toast.error("계약서에 서명을 먼저 완료해주세요.", { + description: "문서의 서명 필드에 서명해주세요.", + icon: + }); + 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: - }); - 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: }); - router.refresh(); - setOpen(false); - if (onSuccess) { - onSuccess(); + + // 다음 미완료 계약서로 자동 이동 + const nextContract = getNextPendingContract(); + if (nextContract) { + setSelectedContract(nextContract); + toast.info(`다음 계약서로 이동합니다`, { + description: nextContract.templateName, + icon: + }); + } else { + // 모든 계약서 완료시 + toast.success("🎉 모든 계약서 서명이 완료되었습니다!", { + description: `총 ${totalCount}개 계약서 서명 완료`, + icon: + }); } + + 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: }); } } 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: + }); + }; + return ( <> {/* 서명 버튼 */} @@ -260,19 +430,48 @@ export function BasicContractSignDialog({ - {/* 서명 다이얼로그 - 레이아웃 개선 */} + {/* 서명 다이얼로그 */} - {/* 고정 헤더 */} + {/* 고정 헤더 - 진행 상황 표시 */} - - - {t("basicContracts.dialog.title")} - {/* 추가 파일 로딩 표시 */} - {isLoadingAttachments && ( - + +
+ + {t("basicContracts.dialog.title")} + {/* 진행 상황 표시 */} + + {completedCount}/{totalCount} 완료 + + {/* 추가 파일 로딩 표시 */} + {isLoadingAttachments && ( + + )} +
+ + {allCompleted && ( + + + 전체 완료! + )}
+ + {/* 진행률 바 */} + {totalCount > 1 && ( +
+
+ 전체 진행률 + {Math.round((completedCount / totalCount) * 100)}% +
+
+
+
+
+ )} {/* 메인 컨텐츠 영역 - Flexbox 사용 */} @@ -302,49 +501,101 @@ export function BasicContractSignDialog({
) : (
- {filteredContracts.map((contract) => ( - - ))} + + ); + })}
)} @@ -360,12 +611,31 @@ export function BasicContractSignDialog({

{selectedContract.templateName || t("basicContracts.dialog.document")} + + {/* 현재 계약서 상태 표시 */} + {currentContractStatus?.status === 'completed' ? ( + + + 서명 완료 + + ) : currentContractStatus?.status === 'error' ? ( + + + 처리 실패 + + ) : ( + + 서명 대기 + + )} + {/* 준법 템플릿 표시 */} {selectedContract.templateName?.includes('준법') && ( 준법 서류 )} + {/* 비밀유지 계약서인 경우 추가 파일 수 표시 */} {selectedContract.templateName === "비밀유지 계약서" && additionalFiles.length > 0 && ( @@ -388,59 +658,128 @@ export function BasicContractSignDialog({ {/* 뷰어 영역 - 남은 공간 모두 사용 */}
handleSurveyComplete(selectedContract.id)} // 🔥 추가 + onSignatureComplete={() => handleSignatureComplete(selectedContract.id)} // 🔥 추가 t={t} />
- {/* 고정 푸터 */} + {/* 고정 푸터 - 동적 버튼 */}
-

- - {t("basicContracts.dialog.signWarning")} -

- {/* 준법 템플릿인 경우 추가 안내 */} - {selectedContract.templateName?.includes('준법') && ( -

- - 모든 준법 항목을 체크해주세요 + {/* 현재 계약서가 완료된 경우 */} + {currentContractStatus?.status === 'completed' ? ( +

+ + 이 계약서는 이미 서명이 완료되었습니다

- )} - {/* 비밀유지 계약서인 경우 추가 안내 */} - {selectedContract.templateName === "비밀유지 계약서" && additionalFiles.length > 0 && ( -

- - 첨부 서류도 확인해주세요 + ) : currentContractStatus?.status === 'error' ? ( +

+ + 서명 처리 중 오류가 발생했습니다. 다시 시도해주세요.

- )} -
- +
+ + {/* 동적 버튼 영역 */} +
+ {allCompleted ? ( + // 모든 계약서 완료시 + + ) : currentContractStatus?.status === 'completed' ? ( + // 현재 계약서가 완료된 경우 + + ) : ( + // 현재 계약서를 서명해야 하는 경우 + + )} +
) : ( 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; + 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 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 체크 (실제 파일이 있는 경우) + 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): 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); + + 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): { + 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; + + 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): { + 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 + }; + + 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): 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; +} \ No newline at end of file -- cgit v1.2.3