diff options
Diffstat (limited to 'lib/basic-contract/viewer/SurveyComponent.tsx')
| -rw-r--r-- | lib/basic-contract/viewer/SurveyComponent.tsx | 922 |
1 files changed, 922 insertions, 0 deletions
diff --git a/lib/basic-contract/viewer/SurveyComponent.tsx b/lib/basic-contract/viewer/SurveyComponent.tsx new file mode 100644 index 00000000..299fe6fa --- /dev/null +++ b/lib/basic-contract/viewer/SurveyComponent.tsx @@ -0,0 +1,922 @@ +import React, { useState, useEffect, useRef, useMemo, useCallback } from "react"; +import { useForm, useWatch, Controller } from "react-hook-form"; +import { Loader2, FileText, ClipboardList, AlertTriangle, CheckCircle2, Upload, ChevronDown, ChevronUp } from "lucide-react"; +import { toast } from "sonner"; +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; +import { Label } from "@/components/ui/label"; +import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group"; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; +import { Input } from "@/components/ui/input"; +import { Textarea } from "@/components/ui/textarea"; +import { + CompleteSurveyRequest, + ExistingResponse, + SurveyAnswerData, + completeSurvey, + getExistingSurveyResponse, + type SurveyTemplateWithQuestions +} from '../service'; +import { ConditionalSurveyHandler } from '../vendor-table/survey-conditional'; + +// 폼 데이터 타입 정의 +interface SurveyFormData { + [key: string]: { + answerValue?: string; + detailText?: string; + otherText?: string; + files?: File[]; + }; +} + +interface SurveyComponentProps { + contractId?: number; + surveyTemplate: SurveyTemplateWithQuestions | null; + surveyLoading: boolean; + conditionalHandler: ConditionalSurveyHandler | null; + onSurveyComplete?: () => void; + onSurveyDataUpdate: (data: any) => void; + onLoadSurveyTemplate: () => void; + setActiveTab: (tab: string) => void; +} + +export const SurveyComponent: React.FC<SurveyComponentProps> = ({ + contractId, + surveyTemplate, + surveyLoading, + conditionalHandler, + onSurveyComplete, + onSurveyDataUpdate, + onLoadSurveyTemplate, + setActiveTab +}) => { + const [uploadedFiles, setUploadedFiles] = useState<Record<number, File[]>>({}); + const [existingResponse, setExistingResponse] = useState<ExistingResponse | null>(null); + const [loadingExistingResponse, setLoadingExistingResponse] = useState(false); + const [isSubmitting, setIsSubmitting] = useState(false); + const [formInitialized, setFormInitialized] = useState(false); + const [isHeaderExpanded, setIsHeaderExpanded] = useState(false); + + console.log(uploadedFiles,"uploadedFiles") + + // 무한 렌더링 방지를 위한 ref + const loadingRef = useRef(false); + const initializedRef = useRef(false); + + // 기본 폼 설정 - 의존성 최소화 + const { control, watch, setValue, getValues, formState: { errors }, trigger, reset } = useForm<SurveyFormData>({ + defaultValues: {}, + mode: 'onChange' + }); + + const watchedValues = watch(); + + + // 기존 응답 로드 - 한 번만 실행되도록 최적화 + useEffect(() => { + // 중복 실행 방지 + if (loadingRef.current || !contractId || !surveyTemplate?.id || initializedRef.current) { + return; + } + + loadingRef.current = true; + setLoadingExistingResponse(true); + + const loadExistingResponse = async () => { + try { + console.log('📥 기존 설문 응답 조회 시작...', { contractId, templateId: surveyTemplate.id }); + + const result = await getExistingSurveyResponse(contractId, surveyTemplate.id); + + if (result.success && result.data) { + console.log('✅ 기존 응답 발견:', result.data); + setExistingResponse(result.data); + + // 폼 초기값 설정 + const formValues: SurveyFormData = {}; + const existingFiles: Record<number, File[]> = {}; + + result.data.answers.forEach(answer => { + formValues[answer.questionId] = { + answerValue: answer.answerValue || '', + detailText: answer.detailText || '', + otherText: answer.otherText || '', + files: answer.files || [], // 기존 파일 정보 유지 + }; + + // 파일이 있다면 uploadedFiles에도 설정 (표시용) + if (answer.files && answer.files.length > 0) { + existingFiles[answer.questionId] = answer.files; + } + }); + + console.log('📝 폼 초기값 설정:', formValues); + + // reset을 사용하여 폼 전체를 한 번에 초기화 + reset(formValues); + setUploadedFiles(existingFiles) + + // 기존 응답이 완료되었다면 부모에게 알림 + if (result.data.status === 'COMPLETED') { + const completedData = { + completed: true, + answers: result.data.answers, + timestamp: result.data.completedAt || new Date().toISOString(), + responseId: result.data.responseId, + }; + + onSurveyDataUpdate(completedData); + + // ⭐ 여기가 핵심: 기존 완료된 설문이 있으면 즉시 부모의 설문 완료 상태 업데이트 + if (onSurveyComplete) { + console.log(`📋 기존 완료된 설문조사 감지 - 부모 상태 업데이트: 계약서 ${contractId}`); + onSurveyComplete(); + } + } + + } else { + console.log('📭 기존 응답 없음'); + setExistingResponse(null); + } + + initializedRef.current = true; + setFormInitialized(true); + + } catch (error) { + console.error('❌ 기존 응답 로드 중 오류:', error); + setExistingResponse(null); + initializedRef.current = true; + setFormInitialized(true); + } finally { + setLoadingExistingResponse(false); + loadingRef.current = false; + } + }; + + loadExistingResponse(); + }, [contractId, surveyTemplate?.id,onSurveyComplete]); // 의존성 최소화 + + // 실시간 진행 상태 계산 - 안정화 + const progressStatus = useMemo(() => { + if (!formInitialized || !conditionalHandler || !surveyTemplate) { + return { + visibleQuestions: [], + totalRequired: 0, + completedRequired: 0, + completedQuestionIds: [], + incompleteQuestionIds: [], + progressPercentage: 0, + debugInfo: {} + }; + } + + // 현재 답변을 조건부 핸들러가 인식할 수 있는 형태로 변환 + const convertedAnswers: Record<number, any> = {}; + Object.entries(watchedValues || {}).forEach(([questionId, value]) => { + const id = parseInt(questionId); + if (!isNaN(id) && value) { + convertedAnswers[id] = { + questionId: id, + answerValue: value.answerValue || '', + detailText: value.detailText || '', + otherText: value.otherText || '', + files: value.files || [] + }; + } + }); + + console.log(convertedAnswers,"convertedAnswers") + + return conditionalHandler.getSimpleProgressStatus(convertedAnswers); + }, [conditionalHandler, watchedValues, surveyTemplate, formInitialized]); + + console.log(progressStatus,"progressStatus") + + // 동적 상태 정보 - 메모화 + const { + visibleQuestions, + totalVisibleQuestions, + baseQuestionCount, + conditionalQuestionCount, + hasConditionalQuestions, + canComplete + } = useMemo(() => { + 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; + + return { + visibleQuestions, + totalVisibleQuestions, + baseQuestionCount, + conditionalQuestionCount, + hasConditionalQuestions, + canComplete + }; + }, [progressStatus, surveyTemplate?.questions]); + + // 파일 업로드 핸들러 - 안정적인 참조 + 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); + + setValue(`${questionId}.${field}`, value); + + // 부모 질문의 답변이 변경되면 조건부 자식 질문들 처리 + if (field === 'answerValue' && conditionalHandler) { + // setTimeout으로 다음 tick에서 처리하여 상태 업데이트 충돌 방지 + setTimeout(() => { + const currentValues = getValues(); + const convertedAnswers: Record<number, any> = {}; + + Object.entries(currentValues).forEach(([qId, qValue]) => { + const id = parseInt(qId); + if (!isNaN(id) && qValue) { + convertedAnswers[id] = { + questionId: id, + answerValue: qValue.answerValue || '', + detailText: qValue.detailText || '', + otherText: qValue.otherText || '', + files: qValue.files || [] + }; + } + }); + + // 새로운 답변 반영 + convertedAnswers[questionId] = { + ...convertedAnswers[questionId], + questionId, + [field]: value + }; + + // 영향받는 자식 질문들의 답변 초기화 + const clearedAnswers = conditionalHandler.clearAffectedChildAnswers(questionId, value, convertedAnswers); + + // 삭제된 답변들을 폼에서도 제거 + 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; + }); + } + }); + }, 0); + } + }, [setValue, getValues, conditionalHandler]); + + // OTHER 텍스트 입력 컴포넌트 + const OtherTextInput = useCallback(({ questionId, fieldName }: { questionId: number; fieldName: string }) => { + const answerValue = useWatch({ + control, + name: `${fieldName}.answerValue` + }); + + const question = visibleQuestions.find(q => q.id === questionId); + const selectedOption = question?.options?.find(opt => opt.optionValue === answerValue); + + if (!selectedOption?.allowsOtherInput) return null; + + return ( + <Controller + name={`${fieldName}.otherText`} + control={control} + render={({ field }) => ( + <Input + {...field} + placeholder="기타 내용을 입력해주세요" + className="mt-2" + /> + )} + /> + ); + }, [control, visibleQuestions]); + + // 설문조사 완료 핸들러 + const handleSurveyComplete = useCallback(async () => { + console.log('🎯 설문조사 완료 시도'); + + if (isSubmitting) { + console.log('⚠️ 이미 제출 중...'); + return; + } + + setIsSubmitting(true); + + try { + const currentValues = getValues(); + const isValid = await trigger(); + + 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 + }; + + const submitToast = toast.loading('설문조사를 저장하는 중...', { + description: '잠시만 기다려주세요.', + duration: Infinity + }); + + const result = await completeSurvey(requestData); + toast.dismiss(submitToast); + + if (result.success) { + const completedSurveyData = { + completed: true, + answers: surveyAnswers, + timestamp: new Date().toISOString(), + progressStatus: progressStatus, + totalQuestions: totalVisibleQuestions, + conditionalQuestions: conditionalQuestionCount, + responseId: result.data?.responseId + }; + + onSurveyDataUpdate(completedSurveyData); + + if (onSurveyComplete) { + onSurveyComplete(); + } + + toast.success("🎉 설문조사가 완료되었습니다!", { + description: `총 ${progressStatus.totalRequired}개 필수 질문 완료${hasConditionalQuestions ? ` (조건부 ${conditionalQuestionCount}개 포함)` : ''}`, + icon: <CheckCircle2 className="h-5 w-5 text-green-500" />, + duration: 5000 + }); + + 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, + canComplete, + progressStatus, + visibleQuestions, + contractId, + surveyTemplate?.id, + totalVisibleQuestions, + conditionalQuestionCount, + hasConditionalQuestions, + isSubmitting, + onSurveyComplete, + onSurveyDataUpdate, + setActiveTab + ]); + + if (surveyLoading || loadingExistingResponse) { + 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"> + {surveyLoading ? '설문조사를 불러오는 중...' : '기존 응답을 확인하는 중...'} + </p> + </CardContent> + </Card> + </div> + ); + } + + if (!surveyTemplate) { + return ( + <div className="h-full w-full"> + <Card className="h-full"> + <CardContent className="flex flex-col items-center justify-center h-full py-12"> + <AlertTriangle className="h-8 w-8 text-red-500 mb-4" /> + <p className="text-sm text-muted-foreground">설문조사 템플릿을 불러올 수 없습니다.</p> + <Button + variant="outline" + onClick={onLoadSurveyTemplate} + className="mt-2" + > + 다시 시도 + </Button> + </CardContent> + </Card> + </div> + ); + } + + return ( + <div className="h-full w-full flex flex-col"> + <Card className="h-full flex flex-col"> + <CardHeader className="pb-2"> + {/* 항상 보이는 컴팩트 헤더 */} + <div className="flex items-center justify-between"> + <CardTitle className="flex items-center text-lg"> + <ClipboardList className="h-5 w-5 mr-2 text-amber-500" /> + <span className="truncate max-w-[300px]">{surveyTemplate.name}</span> + {existingResponse && ( + <Badge + variant={existingResponse.status === 'COMPLETED' ? 'default' : 'secondary'} + className="ml-2 text-xs" + > + {existingResponse.status === 'COMPLETED' ? '완료됨' : '작성중'} + </Badge> + )} + </CardTitle> + + <div className="flex items-center space-x-3"> + {/* 컴팩트 진행률 표시 */} + <div className="flex items-center space-x-2"> + <div className="w-20 bg-gray-200 rounded-full h-1.5"> + <div + className="bg-gradient-to-r from-blue-500 to-blue-600 h-1.5 rounded-full transition-all duration-300" + style={{ width: `${progressStatus.progressPercentage}%` }} + /> + </div> + <span className="text-xs text-gray-600 whitespace-nowrap"> + {progressStatus.completedRequired}/{progressStatus.totalRequired} + </span> + </div> + + {/* 펼치기/접기 버튼 */} + <Button + variant="ghost" + size="sm" + onClick={() => setIsHeaderExpanded(!isHeaderExpanded)} + className="p-1 h-8 w-8" + > + {isHeaderExpanded ? ( + <ChevronUp className="h-4 w-4" /> + ) : ( + <ChevronDown className="h-4 w-4" /> + )} + </Button> + </div> + </div> + + {/* 접을 수 있는 상세 정보 - 조건부 렌더링 */} + {isHeaderExpanded && ( + <div className="mt-3 pt-3 space-y-3 border-t"> + {/* 기존 응답 정보 */} + {existingResponse && ( + <div className="p-2 bg-blue-50 border border-blue-200 rounded-lg"> + <div className="flex items-center text-sm"> + <CheckCircle2 className="h-4 w-4 text-blue-600 mr-2" /> + <span className="text-blue-800"> + {existingResponse.status === 'COMPLETED' + ? '이미 완료된 설문조사입니다. 내용을 수정할 수 있습니다.' + : '이전에 작성하던 내용이 복원되었습니다.'} + </span> + </div> + {existingResponse.completedAt && ( + <div className="text-xs text-blue-600 mt-1"> + 완료일시: {new Date(existingResponse.completedAt).toLocaleString('ko-KR')} + </div> + )} + </div> + )} + + {/* 질문 정보 - 그리드로 컴팩트하게 */} + {/* <div className="grid grid-cols-2 gap-4 text-sm"> + <div className="space-y-1"> + <div className="text-gray-600"> + 📋 총 {totalVisibleQuestions}개 질문 + </div> + {hasConditionalQuestions && ( + <div className="text-blue-600 text-xs"> + ⚡ 조건부 {conditionalQuestionCount}개 추가됨 + </div> + )} + </div> + + <div className="space-y-1"> + <div className="text-gray-600"> + ✅ 완료: {progressStatus.completedRequired}개 + </div> + <div className="text-gray-600"> + ⏳ 남은 필수: {progressStatus.totalRequired - progressStatus.completedRequired}개 + </div> + </div> + </div> */} + + {/* 상세 진행률 바 */} + {/* <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-3"> + <div + className="bg-gradient-to-r from-blue-500 to-blue-600 h-3 rounded-full transition-all duration-500 ease-out" + style={{ width: `${progressStatus.progressPercentage}%` }} + /> + </div> + </div> */} + + {/* 중요 안내 - 컴팩트하게 */} + <div className="p-3 border rounded-lg bg-yellow-50 border-yellow-200"> + <div className="flex items-start"> + <AlertTriangle className="h-4 w-4 text-yellow-600 mt-0.5 mr-2 flex-shrink-0" /> + <div> + <p className="font-medium text-yellow-800 text-sm">준법 의무 확인 필수</p> + <p className="text-xs text-yellow-700 mt-1"> + 모든 필수 항목을 정확히 작성해주세요. 답변에 따라 추가 질문이 나타날 수 있습니다. + </p> + </div> + </div> + </div> + </div> + )} + + {/* 헤더가 접혀있을 때 보이는 요약 정보 */} + {!isHeaderExpanded && ( + <div className="flex items-center justify-between text-xs text-gray-500 mt-2 pt-2 border-t"> + <span> + 📋 {totalVisibleQuestions}개 질문 + {hasConditionalQuestions && ( + <span className="text-blue-600 ml-1">(+{conditionalQuestionCount}개 조건부)</span> + )} + </span> + <span + className="text-blue-600 hover:underline cursor-pointer" + onClick={() => setIsHeaderExpanded(true)} + > + 상세 정보 보기 ↑ + </span> + </div> + )} +</CardHeader> + + <CardContent className="flex-1 min-h-0 overflow-y-auto"> + <form onSubmit={(e) => e.preventDefault()} className="space-y-6"> + {/* <div className="p-4 border rounded-lg bg-yellow-50"> + <div className="flex items-start"> + <AlertTriangle className="h-5 w-5 text-yellow-600 mt-0.5 mr-2" /> + <div> + <p className="font-medium text-yellow-800">중요 안내</p> + <p className="text-sm text-yellow-700 mt-1"> + 본 설문조사는 준법 의무 확인을 위한 필수 절차입니다. 모든 항목을 정확히 작성해주세요. + {conditionalHandler && ( + <span className="block mt-1"> + ⚡ 답변에 따라 추가 질문이 나타날 수 있으며, 이 경우 모든 추가 질문도 완료해야 합니다. + </span> + )} + </p> + </div> + </div> + </div> */} + + <div className="space-y-4"> + {visibleQuestions.map((question: any) => { + const fieldName = `${question.id}`; + const isComplete = progressStatus.completedQuestionIds.includes(question.id); + const isConditional = !!question.parentQuestionId; + + return ( + <div + key={question.id} + id={`question-${question.id}`} + className={`mb-6 p-4 border rounded-lg transition-colors duration-200 ${ + isConditional + ? 'bg-blue-50 border-blue-200' + : 'bg-gray-50 border-gray-200' + } ${!isComplete && question.isRequired ? 'ring-2 ring-red-100' : ''}`} + > + <div className="flex items-start justify-between mb-3"> + <div className="flex-1"> + <Label className="text-sm font-medium text-gray-900 flex items-center flex-wrap gap-2"> + <span className="px-2 py-1 bg-blue-100 text-blue-700 rounded text-xs"> + Q{question.questionNumber} + </span> + + {isConditional && ( + <span className="px-2 py-1 bg-amber-100 text-amber-700 rounded text-xs"> + ⚡ 조건부 질문 + </span> + )} + + {question.questionType === 'FILE' && ( + <span className="px-2 py-1 bg-green-100 text-green-700 rounded text-xs"> + 📎 파일 업로드 + </span> + )} + + <div className="w-full mt-1"> + {question.questionText} + {question.isRequired && <span className="text-red-500 ml-1">*</span>} + </div> + </Label> + </div> + + {isComplete && ( + <CheckCircle2 className="h-5 w-5 text-green-500 ml-2 flex-shrink-0" /> + )} + </div> + + {/* 질문 타입별 렌더링 */} + {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> + )} + /> + )} + + {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> + )} + + {question.questionType === 'TEXTAREA' && ( + <Controller + name={`${fieldName}.detailText`} + control={control} + rules={{ required: question.isRequired ? '필수 항목입니다.' : false }} + render={({ field }) => ( + <Textarea + {...field} + placeholder="상세한 내용을 입력해주세요" + rows={4} + /> + )} + /> + )} + + {question.hasDetailText && ( + <div className="mt-3"> + <Label className="text-sm text-gray-700 mb-2 block">상세 내용을 기술해주세요:</Label> + <Controller + name={`${fieldName}.detailText`} + control={control} + rules={{ required: question.isRequired ? '상세 내용을 입력해주세요.' : false }} + render={({ field }) => ( + <Textarea + {...field} + placeholder="상세한 내용을 입력해주세요" + rows={3} + className="w-full" + /> + )} + /> + </div> + )} + + {(question.hasFileUpload || question.questionType === 'FILE') && ( + <div className="mt-3"> + <Label className="text-sm text-gray-700 mb-2 block">첨부파일:</Label> + <div className="border-2 border-dashed border-gray-300 rounded-lg p-4"> + <input + type="file" + multiple + onChange={(e) => handleFileUpload(question.id, e.target.files)} + className="hidden" + id={`file-${question.id}`} + /> + <label htmlFor={`file-${question.id}`} className="cursor-pointer"> + <div className="flex flex-col items-center"> + <Upload className="h-8 w-8 text-gray-400 mb-2" /> + <span className="text-sm text-gray-500">파일을 선택하거나 여기에 드래그하세요</span> + </div> + </label> + + {uploadedFiles[question.id] && uploadedFiles[question.id].length > 0 && ( + <div className="mt-3 space-y-1"> + {uploadedFiles[question.id].map((file, index) => ( + <div key={index} className="flex items-center space-x-2 text-sm"> + <FileText className="h-4 w-4 text-blue-500" /> + <span>{file.fileName}</span> + <span className="text-gray-500">({(file.fileSize / 1024).toFixed(1)} KB)</span> + </div> + ))} + </div> + )} + </div> + </div> + )} + + {errors[fieldName] && ( + <p className="mt-2 text-sm text-red-600 flex items-center"> + <AlertTriangle className="h-4 w-4 mr-1" /> + {errors[fieldName]?.answerValue?.message || + errors[fieldName]?.detailText?.message || + '필수 항목을 완료해주세요.'} + </p> + )} + </div> + ); + })} + </div> + + <div className="flex justify-end pt-6 border-t"> + <div className="flex items-center space-x-4"> + <div className="text-sm"> + {canComplete ? ( + <div className="text-green-600 font-medium flex items-center"> + <CheckCircle2 className="h-4 w-4 mr-1" /> + 모든 필수 항목 완료됨 + {hasConditionalQuestions && ( + <span className="ml-2 text-xs text-blue-600"> + (조건부 {conditionalQuestionCount}개 포함) + </span> + )} + </div> + ) : ( + <div className="space-y-1"> + <div className="flex items-center text-gray-600"> + <AlertTriangle className="h-4 w-4 mr-1 text-red-500" /> + {progressStatus.completedRequired}/{progressStatus.totalRequired} 완료 + </div> + {hasConditionalQuestions && ( + <div className="text-xs text-blue-600"> + 기본 + 조건부 {conditionalQuestionCount}개 포함 + </div> + )} + </div> + )} + </div> + + <Button + type="button" + onClick={handleSurveyComplete} + disabled={!canComplete || isSubmitting} + className={`transition-all duration-200 ${ + canComplete && !isSubmitting + ? 'bg-green-600 hover:bg-green-700 shadow-lg' + : 'bg-gray-400 cursor-not-allowed' + }`} + > + {isSubmitting ? ( + <> + <Loader2 className="h-4 w-4 mr-2 animate-spin" /> + 저장 중... + </> + ) : ( + <> + <CheckCircle2 className="h-4 w-4 mr-2" /> + 설문조사 완료 + </> + )} + <span className="ml-1 text-xs"> + ({progressStatus.completedRequired}/{progressStatus.totalRequired}) + </span> + </Button> + </div> + </div> + </form> + </CardContent> + </Card> + </div> + ); +};
\ No newline at end of file |
