summaryrefslogtreecommitdiff
path: root/lib/basic-contract/viewer/SurveyComponent.tsx
diff options
context:
space:
mode:
Diffstat (limited to 'lib/basic-contract/viewer/SurveyComponent.tsx')
-rw-r--r--lib/basic-contract/viewer/SurveyComponent.tsx922
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