summaryrefslogtreecommitdiff
path: root/lib/basic-contract/vendor-table
diff options
context:
space:
mode:
Diffstat (limited to 'lib/basic-contract/vendor-table')
-rw-r--r--lib/basic-contract/vendor-table/basic-contract-sign-dialog.tsx561
-rw-r--r--lib/basic-contract/vendor-table/basic-contract-table.tsx1
-rw-r--r--lib/basic-contract/vendor-table/survey-conditional.ts860
3 files changed, 1310 insertions, 112 deletions
diff --git a/lib/basic-contract/vendor-table/basic-contract-sign-dialog.tsx b/lib/basic-contract/vendor-table/basic-contract-sign-dialog.tsx
index 7d828a7e..319ae4b9 100644
--- a/lib/basic-contract/vendor-table/basic-contract-sign-dialog.tsx
+++ b/lib/basic-contract/vendor-table/basic-contract-sign-dialog.tsx
@@ -19,7 +19,10 @@ import {
User,
AlertCircle,
Calendar,
- Loader2
+ Loader2,
+ ArrowRight,
+ Trophy,
+ Target
} from "lucide-react";
import { Input } from "@/components/ui/input";
import { Badge } from "@/components/ui/badge";
@@ -28,6 +31,13 @@ import { useRouter } from "next/navigation"
import { BasicContractSignViewer } from "../viewer/basic-contract-sign-viewer";
import { getVendorAttachments } from "../service";
+// 계약서 상태 타입 정의
+interface ContractStatus {
+ id: number;
+ status: 'pending' | 'completed' | 'error';
+ errorMessage?: string;
+}
+
interface BasicContractSignDialogProps {
contracts: BasicContractView[];
onSuccess?: () => void;
@@ -51,6 +61,13 @@ export function BasicContractSignDialog({
const [additionalFiles, setAdditionalFiles] = React.useState<any[]>([]);
const [isLoadingAttachments, setIsLoadingAttachments] = React.useState(false);
+ // 계약서 상태 관리
+ const [contractStatuses, setContractStatuses] = React.useState<ContractStatus[]>([]);
+
+ // 🔥 새로 추가: 서명/설문 완료 상태 관리
+ const [surveyCompletionStatus, setSurveyCompletionStatus] = React.useState<Record<number, boolean>>({});
+ const [signatureStatus, setSignatureStatus] = React.useState<Record<number, boolean>>({});
+
const router = useRouter()
console.log(selectedContract,"selectedContract")
@@ -70,15 +87,81 @@ export function BasicContractSignDialog({
return "";
};
+ // 🔥 현재 선택된 계약서의 서명 완료 가능 여부 확인
+ const canCompleteCurrentContract = React.useMemo(() => {
+ if (!selectedContract) return false;
+
+ const contractId = selectedContract.id;
+ const isComplianceTemplate = selectedContract.templateName?.includes('준법');
+
+ // 1. 준법 템플릿인 경우 설문조사 완료 여부 확인
+ const surveyCompleted = isComplianceTemplate ? surveyCompletionStatus[contractId] === true : true;
+
+ // 2. 서명 완료 여부 확인
+ const signatureCompleted = signatureStatus[contractId] === true;
+
+ console.log('🔍 서명 완료 가능 여부 체크:', {
+ contractId,
+ isComplianceTemplate,
+ surveyCompleted,
+ signatureCompleted,
+ canComplete: surveyCompleted && signatureCompleted
+ });
+
+ return surveyCompleted && signatureCompleted;
+ }, [selectedContract, surveyCompletionStatus, signatureStatus]);
+
+ // 계약서별 상태 초기화
+ React.useEffect(() => {
+ if (contracts.length > 0 && contractStatuses.length === 0) {
+ setContractStatuses(
+ contracts.map(contract => ({
+ id: contract.id,
+ status: 'pending' as const
+ }))
+ );
+ }
+ }, [contracts, contractStatuses.length]);
+
+ // 완료된 계약서 수 계산
+ const completedCount = contractStatuses.filter(status => status.status === 'completed').length;
+ const totalCount = contracts.length;
+ const allCompleted = completedCount === totalCount && totalCount > 0;
+
+ // 현재 선택된 계약서의 상태
+ const currentContractStatus = selectedContract
+ ? contractStatuses.find(status => status.id === selectedContract.id)
+ : null;
+
+ // 다음 미완료 계약서 찾기
+ const getNextPendingContract = () => {
+ const pendingStatuses = contractStatuses.filter(status => status.status === 'pending');
+ if (pendingStatuses.length === 0) return null;
+
+ const nextPendingId = pendingStatuses[0].id;
+ return contracts.find(contract => contract.id === nextPendingId) || null;
+ };
+
// 다이얼로그 열기/닫기 핸들러
const handleOpenChange = (isOpen: boolean) => {
+ if (!isOpen && !allCompleted && completedCount > 0) {
+ // 완료되지 않은 계약서가 있으면 확인 대화상자
+ const confirmClose = window.confirm(
+ `${completedCount}/${totalCount}개 계약서가 완료되었습니다. 정말 나가시겠습니까?`
+ );
+ if (!confirmClose) return;
+ }
+
setOpen(isOpen);
if (!isOpen) {
// 다이얼로그 닫을 때 상태 초기화
setSelectedContract(null);
setSearchTerm("");
- setAdditionalFiles([]); // 추가 파일 상태 초기화
+ setAdditionalFiles([]);
+ setContractStatuses([]);
+ setSurveyCompletionStatus({}); // 🔥 추가
+ setSignatureStatus({}); // 🔥 추가
// WebViewer 인스턴스 정리
if (instance) {
try {
@@ -108,12 +191,17 @@ export function BasicContractSignDialog({
);
}, [contracts, searchTerm]);
- // 다이얼로그가 열릴 때 첫 번째 계약서 자동 선택
+ // 다이얼로그가 열릴 때 첫 번째 미완료 계약서 자동 선택
React.useEffect(() => {
if (open && contracts.length > 0 && !selectedContract) {
- setSelectedContract(contracts[0]);
+ const firstPending = getNextPendingContract();
+ if (firstPending) {
+ setSelectedContract(firstPending);
+ } else {
+ setSelectedContract(contracts[0]);
+ }
}
- }, [open, contracts, selectedContract]);
+ }, [open, contracts, selectedContract, contractStatuses]);
// 추가 파일 가져오기 useEffect
React.useEffect(() => {
@@ -149,10 +237,54 @@ export function BasicContractSignDialog({
fetchAdditionalFiles();
}, [selectedContract]);
- // 서명 완료 핸들러
+ // 🔥 설문조사 완료 콜백 함수
+ const handleSurveyComplete = React.useCallback((contractId: number) => {
+ console.log(`📋 설문조사 완료: 계약서 ${contractId}`);
+ setSurveyCompletionStatus(prev => ({
+ ...prev,
+ [contractId]: true
+ }));
+ }, []);
+
+ // 🔥 서명 완료 콜백 함수
+ const handleSignatureComplete = React.useCallback((contractId: number) => {
+ console.log(`✍️ 서명 완료: 계약서 ${contractId}`);
+ setSignatureStatus(prev => ({
+ ...prev,
+ [contractId]: true
+ }));
+ }, []);
+
+ // 서명 완료 핸들러 (수정됨)
const completeSign = async () => {
if (!instance || !selectedContract) return;
+ // 🔥 서명 완료 가능 여부 재확인
+ if (!canCompleteCurrentContract) {
+ const contractId = selectedContract.id;
+ const isComplianceTemplate = selectedContract.templateName?.includes('준법');
+ const surveyCompleted = isComplianceTemplate ? surveyCompletionStatus[contractId] === true : true;
+ const signatureCompleted = signatureStatus[contractId] === true;
+
+ if (!surveyCompleted) {
+ toast.error("준법 설문조사를 먼저 완료해주세요.", {
+ description: "설문조사 탭에서 모든 필수 항목을 완료해주세요.",
+ icon: <AlertCircle className="h-5 w-5 text-red-500" />
+ });
+ return;
+ }
+
+ if (!signatureCompleted) {
+ toast.error("계약서에 서명을 먼저 완료해주세요.", {
+ description: "문서의 서명 필드에 서명해주세요.",
+ icon: <Target className="h-5 w-5 text-blue-500" />
+ });
+ return;
+ }
+
+ return;
+ }
+
setIsSubmitting(true);
try {
const { documentViewer, annotationManager } = instance.Core;
@@ -183,21 +315,6 @@ export function BasicContractSignDialog({
submitFormData.append('formData', JSON.stringify(formData));
}
- // 준법 템플릿인 경우 필수 필드 검증
- if (selectedContract.templateName?.includes('준법')) {
- const requiredFields = ['compliance_agreement', 'legal_review', 'risk_assessment'];
- const missingFields = requiredFields.filter(field => !formData[field]);
-
- if (missingFields.length > 0) {
- toast.error("필수 준법 항목이 누락되었습니다.", {
- description: `다음 항목을 완료해주세요: ${missingFields.join(', ')}`,
- icon: <AlertCircle className="h-5 w-5 text-red-500" />
- });
- setIsSubmitting(false);
- return;
- }
- }
-
// API 호출
const response = await fetch('/api/upload/signed-contract', {
method: 'POST',
@@ -208,29 +325,82 @@ export function BasicContractSignDialog({
const result = await response.json();
if (result.result) {
- toast.success(t("basicContracts.messages.signSuccess"), {
- description: t("basicContracts.messages.documentProcessed"),
+ // 성공시 해당 계약서 상태를 완료로 업데이트
+ setContractStatuses(prev =>
+ prev.map(status =>
+ status.id === selectedContract.id
+ ? { ...status, status: 'completed' as const }
+ : status
+ )
+ );
+
+ toast.success("계약서 서명이 완료되었습니다!", {
+ description: `${selectedContract.templateName} - ${completedCount + 1}/${totalCount}개 완료`,
icon: <CheckCircle2 className="h-5 w-5 text-green-500" />
});
- router.refresh();
- setOpen(false);
- if (onSuccess) {
- onSuccess();
+
+ // 다음 미완료 계약서로 자동 이동
+ const nextContract = getNextPendingContract();
+ if (nextContract) {
+ setSelectedContract(nextContract);
+ toast.info(`다음 계약서로 이동합니다`, {
+ description: nextContract.templateName,
+ icon: <ArrowRight className="h-4 w-4 text-blue-500" />
+ });
+ } else {
+ // 모든 계약서 완료시
+ toast.success("🎉 모든 계약서 서명이 완료되었습니다!", {
+ description: `총 ${totalCount}개 계약서 서명 완료`,
+ icon: <Trophy className="h-5 w-5 text-yellow-500" />
+ });
}
+
+ router.refresh();
} else {
- toast.error(t("basicContracts.messages.signError"), {
+ // 실패시 에러 상태 업데이트
+ setContractStatuses(prev =>
+ prev.map(status =>
+ status.id === selectedContract.id
+ ? { ...status, status: 'error' as const, errorMessage: result.error }
+ : status
+ )
+ );
+
+ toast.error("서명 처리 중 오류가 발생했습니다", {
description: result.error,
icon: <AlertCircle className="h-5 w-5 text-red-500" />
});
}
} catch (error) {
console.error("서명 완료 중 오류:", error);
- toast.error(t("basicContracts.messages.signError"));
+
+ // 에러 상태 업데이트
+ setContractStatuses(prev =>
+ prev.map(status =>
+ status.id === selectedContract.id
+ ? { ...status, status: 'error' as const, errorMessage: '서명 처리 중 오류가 발생했습니다' }
+ : status
+ )
+ );
+
+ toast.error("서명 처리 중 오류가 발생했습니다");
} finally {
setIsSubmitting(false);
}
};
+ // 모든 서명 완료 핸들러
+ const completeAllSigns = () => {
+ setOpen(false);
+ if (onSuccess) {
+ onSuccess();
+ }
+ toast.success("모든 계약서 서명이 완료되었습니다!", {
+ description: "계약서 관리 페이지가 새로고침됩니다.",
+ icon: <Trophy className="h-5 w-5 text-yellow-500" />
+ });
+ };
+
return (
<>
{/* 서명 버튼 */}
@@ -260,19 +430,48 @@ export function BasicContractSignDialog({
</span>
</Button>
- {/* 서명 다이얼로그 - 레이아웃 개선 */}
+ {/* 서명 다이얼로그 */}
<Dialog open={open} onOpenChange={handleOpenChange}>
<DialogContent className="max-w-7xl w-[95vw] h-[90vh] p-0 flex flex-col overflow-hidden" style={{width:'95vw', maxWidth:'95vw'}}>
- {/* 고정 헤더 */}
+ {/* 고정 헤더 - 진행 상황 표시 */}
<DialogHeader className="px-6 py-4 bg-gradient-to-r from-blue-50 to-purple-50 border-b flex-shrink-0">
- <DialogTitle className="text-xl font-bold flex items-center text-gray-800">
- <FileSignature className="mr-2 h-5 w-5 text-blue-500" />
- {t("basicContracts.dialog.title")}
- {/* 추가 파일 로딩 표시 */}
- {isLoadingAttachments && (
- <Loader2 className="ml-2 h-4 w-4 animate-spin text-blue-500" />
+ <DialogTitle className="text-xl font-bold flex items-center justify-between text-gray-800">
+ <div className="flex items-center">
+ <FileSignature className="mr-2 h-5 w-5 text-blue-500" />
+ {t("basicContracts.dialog.title")}
+ {/* 진행 상황 표시 */}
+ <Badge variant="outline" className="ml-3 bg-blue-50 text-blue-700 border-blue-200">
+ {completedCount}/{totalCount} 완료
+ </Badge>
+ {/* 추가 파일 로딩 표시 */}
+ {isLoadingAttachments && (
+ <Loader2 className="ml-2 h-4 w-4 animate-spin text-blue-500" />
+ )}
+ </div>
+
+ {allCompleted && (
+ <Badge variant="default" className="bg-green-100 text-green-700 border-green-200">
+ <Trophy className="h-4 w-4 mr-1" />
+ 전체 완료!
+ </Badge>
)}
</DialogTitle>
+
+ {/* 진행률 바 */}
+ {totalCount > 1 && (
+ <div className="mt-3">
+ <div className="flex justify-between text-xs text-gray-600 mb-1">
+ <span>전체 진행률</span>
+ <span>{Math.round((completedCount / totalCount) * 100)}%</span>
+ </div>
+ <div className="w-full bg-gray-200 rounded-full h-2">
+ <div
+ className="bg-gradient-to-r from-blue-500 to-green-500 h-2 rounded-full transition-all duration-500"
+ style={{ width: `${(completedCount / totalCount) * 100}%` }}
+ />
+ </div>
+ </div>
+ )}
</DialogHeader>
{/* 메인 컨텐츠 영역 - Flexbox 사용 */}
@@ -302,49 +501,101 @@ export function BasicContractSignDialog({
</div>
) : (
<div className="space-y-2">
- {filteredContracts.map((contract) => (
- <Button
- key={contract.id}
- variant="outline"
- className={cn(
- "w-full justify-start text-left h-auto p-2 bg-white hover:bg-blue-50 transition-colors",
- "border border-gray-200 hover:border-blue-200 rounded-md",
- selectedContract?.id === contract.id && "border-blue-500 bg-blue-50 shadow-sm"
- )}
- onClick={() => handleSelectContract(contract)}
- >
- <div className="flex flex-col w-full space-y-1">
- {/* 첫 번째 줄: 제목 + 상태 */}
- <div className="flex items-center justify-between w-full">
- <span className="font-medium text-xs truncate text-gray-800 flex items-center min-w-0">
- <FileText className="h-3 w-3 mr-1 text-blue-500 flex-shrink-0" />
- <span className="truncate">{contract.templateName || t("basicContracts.dialog.document")}</span>
- {/* 비밀유지 계약서인 경우 표시 */}
- {contract.templateName === "비밀유지 계약서" && (
- <Badge variant="outline" className="ml-1 bg-green-50 text-green-700 border-green-200 text-xs">
- NDA
+ {filteredContracts.map((contract) => {
+ const contractStatus = contractStatuses.find(status => status.id === contract.id);
+ const isCompleted = contractStatus?.status === 'completed';
+ const hasError = contractStatus?.status === 'error';
+
+ // 🔥 계약서별 완료 상태 확인
+ const isComplianceTemplate = contract.templateName?.includes('준법');
+ const hasSurveyCompleted = isComplianceTemplate ? surveyCompletionStatus[contract.id] === true : true;
+ const hasSignatureCompleted = signatureStatus[contract.id] === true;
+
+ return (
+ <Button
+ key={contract.id}
+ variant="outline"
+ className={cn(
+ "w-full justify-start text-left h-auto p-2 bg-white transition-colors",
+ "border border-gray-200 rounded-md",
+ selectedContract?.id === contract.id && !isCompleted && "border-blue-500 bg-blue-50 shadow-sm",
+ isCompleted && "border-green-200 bg-green-50",
+ hasError && "border-red-200 bg-red-50",
+ !isCompleted && !hasError && "hover:bg-blue-50 hover:border-blue-200"
+ )}
+ onClick={() => handleSelectContract(contract)}
+ disabled={isCompleted}
+ >
+ <div className="flex flex-col w-full space-y-1">
+ {/* 첫 번째 줄: 제목 + 상태 */}
+ <div className="flex items-center justify-between w-full">
+ <span className="font-medium text-xs truncate text-gray-800 flex items-center min-w-0">
+ <FileText className="h-3 w-3 mr-1 text-blue-500 flex-shrink-0" />
+ <span className="truncate">{contract.templateName || t("basicContracts.dialog.document")}</span>
+ {/* 비밀유지 계약서인 경우 표시 */}
+ {contract.templateName === "비밀유지 계약서" && (
+ <Badge variant="outline" className="ml-1 bg-green-50 text-green-700 border-green-200 text-xs">
+ NDA
+ </Badge>
+ )}
+ </span>
+
+ {/* 상태 표시 */}
+ {isCompleted ? (
+ <Badge variant="outline" className="bg-green-50 text-green-700 border-green-200 text-xs ml-2 flex-shrink-0">
+ <CheckCircle2 className="h-3 w-3 mr-1" />
+ 완료
+ </Badge>
+ ) : hasError ? (
+ <Badge variant="outline" className="bg-red-50 text-red-700 border-red-200 text-xs ml-2 flex-shrink-0">
+ <AlertCircle className="h-3 w-3 mr-1" />
+ 오류
+ </Badge>
+ ) : (
+ <Badge variant="outline" className="bg-yellow-50 text-yellow-700 border-yellow-200 text-xs ml-2 flex-shrink-0">
+ 대기
</Badge>
)}
- </span>
- <Badge variant="outline" className="bg-yellow-50 text-yellow-700 border-yellow-200 text-xs ml-2 flex-shrink-0">
- {t("basicContracts.statusValues.PENDING")}
- </Badge>
- </div>
-
- {/* 두 번째 줄: 사용자 + 날짜 */}
- <div className="flex items-center justify-between text-xs text-gray-500">
- <div className="flex items-center min-w-0">
- <User className="h-3 w-3 mr-1 flex-shrink-0" />
- <span className="truncate">{contract.requestedByName || t("basicContracts.dialog.unknown")}</span>
</div>
- <div className="flex items-center ml-2 flex-shrink-0">
- <Calendar className="h-3 w-3 mr-1 flex-shrink-0" />
- <span>{formatDate(contract.createdAt)}</span>
+
+ {/* 🔥 완료 상태 표시 */}
+ {!isCompleted && !hasError && (
+ <div className="flex items-center space-x-2 text-xs">
+ {isComplianceTemplate && (
+ <span className={`flex items-center ${hasSurveyCompleted ? 'text-green-600' : 'text-gray-400'}`}>
+ <CheckCircle2 className={`h-3 w-3 mr-1 ${hasSurveyCompleted ? 'text-green-500' : 'text-gray-300'}`} />
+ 설문
+ </span>
+ )}
+ <span className={`flex items-center ${hasSignatureCompleted ? 'text-green-600' : 'text-gray-400'}`}>
+ <Target className={`h-3 w-3 mr-1 ${hasSignatureCompleted ? 'text-green-500' : 'text-gray-300'}`} />
+ 서명
+ </span>
+ </div>
+ )}
+
+ {/* 두 번째 줄: 사용자 + 날짜 */}
+ <div className="flex items-center justify-between text-xs text-gray-500">
+ <div className="flex items-center min-w-0">
+ <User className="h-3 w-3 mr-1 flex-shrink-0" />
+ <span className="truncate">{contract.requestedByName || t("basicContracts.dialog.unknown")}</span>
+ </div>
+ <div className="flex items-center ml-2 flex-shrink-0">
+ <Calendar className="h-3 w-3 mr-1 flex-shrink-0" />
+ <span>{formatDate(contract.createdAt)}</span>
+ </div>
</div>
+
+ {/* 에러 메시지 표시 */}
+ {hasError && contractStatus?.errorMessage && (
+ <div className="text-xs text-red-600 mt-1">
+ {contractStatus.errorMessage}
+ </div>
+ )}
</div>
- </div>
- </Button>
- ))}
+ </Button>
+ );
+ })}
</div>
)}
</div>
@@ -360,12 +611,31 @@ export function BasicContractSignDialog({
<h3 className="font-semibold text-gray-800 flex items-center">
<FileText className="h-4 w-4 mr-2 text-blue-500" />
{selectedContract.templateName || t("basicContracts.dialog.document")}
+
+ {/* 현재 계약서 상태 표시 */}
+ {currentContractStatus?.status === 'completed' ? (
+ <Badge variant="outline" className="ml-2 bg-green-50 text-green-700 border-green-200">
+ <CheckCircle2 className="h-3 w-3 mr-1" />
+ 서명 완료
+ </Badge>
+ ) : currentContractStatus?.status === 'error' ? (
+ <Badge variant="outline" className="ml-2 bg-red-50 text-red-700 border-red-200">
+ <AlertCircle className="h-3 w-3 mr-1" />
+ 처리 실패
+ </Badge>
+ ) : (
+ <Badge variant="outline" className="ml-2 bg-yellow-50 text-yellow-700 border-yellow-200">
+ 서명 대기
+ </Badge>
+ )}
+
{/* 준법 템플릿 표시 */}
{selectedContract.templateName?.includes('준법') && (
<Badge variant="outline" className="ml-2 bg-amber-50 text-amber-700 border-amber-200">
준법 서류
</Badge>
)}
+
{/* 비밀유지 계약서인 경우 추가 파일 수 표시 */}
{selectedContract.templateName === "비밀유지 계약서" && additionalFiles.length > 0 && (
<Badge variant="outline" className="ml-2 bg-blue-50 text-blue-700 border-blue-200">
@@ -388,59 +658,128 @@ export function BasicContractSignDialog({
{/* 뷰어 영역 - 남은 공간 모두 사용 */}
<div className="flex-1 min-h-0 overflow-hidden">
<BasicContractSignViewer
- key={selectedContract.id} // key 추가로 컴포넌트 재생성 강제
+ key={selectedContract.id}
contractId={selectedContract.id}
filePath={selectedContract.signedFilePath || undefined}
templateName={selectedContract.templateName || ""}
- additionalFiles={additionalFiles} // 추가 파일 전달
+ additionalFiles={additionalFiles}
instance={instance}
setInstance={setInstance}
+ onSurveyComplete={() => handleSurveyComplete(selectedContract.id)} // 🔥 추가
+ onSignatureComplete={() => handleSignatureComplete(selectedContract.id)} // 🔥 추가
t={t}
/>
</div>
- {/* 고정 푸터 */}
+ {/* 고정 푸터 - 동적 버튼 */}
<div className="p-4 flex justify-between items-center bg-gray-50 border-t flex-shrink-0">
<div className="flex items-center space-x-4">
- <p className="text-sm text-gray-600 flex items-center">
- <AlertCircle className="h-4 w-4 text-yellow-500 mr-1" />
- {t("basicContracts.dialog.signWarning")}
- </p>
- {/* 준법 템플릿인 경우 추가 안내 */}
- {selectedContract.templateName?.includes('준법') && (
- <p className="text-xs text-amber-600 flex items-center">
- <AlertCircle className="h-3 w-3 text-amber-500 mr-1" />
- 모든 준법 항목을 체크해주세요
+ {/* 현재 계약서가 완료된 경우 */}
+ {currentContractStatus?.status === 'completed' ? (
+ <p className="text-sm text-green-600 flex items-center">
+ <CheckCircle2 className="h-4 w-4 text-green-500 mr-1" />
+ 이 계약서는 이미 서명이 완료되었습니다
</p>
- )}
- {/* 비밀유지 계약서인 경우 추가 안내 */}
- {selectedContract.templateName === "비밀유지 계약서" && additionalFiles.length > 0 && (
- <p className="text-xs text-blue-600 flex items-center">
- <FileText className="h-3 w-3 text-blue-500 mr-1" />
- 첨부 서류도 확인해주세요
+ ) : currentContractStatus?.status === 'error' ? (
+ <p className="text-sm text-red-600 flex items-center">
+ <AlertCircle className="h-4 w-4 text-red-500 mr-1" />
+ 서명 처리 중 오류가 발생했습니다. 다시 시도해주세요.
</p>
- )}
- </div>
- <Button
- className="gap-2 bg-blue-600 hover:bg-blue-700 transition-colors"
- onClick={completeSign}
- disabled={isSubmitting}
- >
- {isSubmitting ? (
- <>
- <svg className="animate-spin -ml-1 mr-2 h-4 w-4 text-white" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
- <circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"></circle>
- <path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
- </svg>
- {t("basicContracts.dialog.processing")}
- </>
) : (
<>
- <FileSignature className="h-4 w-4" />
- {t("basicContracts.dialog.completeSign")}
+ {/* 🔥 완료 조건 안내 메시지 개선 */}
+ <div className="flex flex-col space-y-1">
+ <p className="text-sm text-gray-600 flex items-center">
+ <AlertCircle className="h-4 w-4 text-yellow-500 mr-1" />
+ {t("basicContracts.dialog.signWarning")}
+ </p>
+
+ {/* 완료 상태 체크리스트 */}
+ <div className="flex items-center space-x-4 text-xs">
+ {selectedContract.templateName?.includes('준법') && (
+ <span className={`flex items-center ${surveyCompletionStatus[selectedContract.id] ? 'text-green-600' : 'text-red-600'}`}>
+ <CheckCircle2 className={`h-3 w-3 mr-1 ${surveyCompletionStatus[selectedContract.id] ? 'text-green-500' : 'text-red-500'}`} />
+ 설문조사 {surveyCompletionStatus[selectedContract.id] ? '완료' : '미완료'}
+ </span>
+ )}
+ <span className={`flex items-center ${signatureStatus[selectedContract.id] ? 'text-green-600' : 'text-red-600'}`}>
+ <Target className={`h-3 w-3 mr-1 ${signatureStatus[selectedContract.id] ? 'text-green-500' : 'text-red-500'}`} />
+ 서명 {signatureStatus[selectedContract.id] ? '완료' : '미완료'}
+ </span>
+ </div>
+ </div>
+
+ {/* 비밀유지 계약서인 경우 추가 안내 */}
+ {selectedContract.templateName === "비밀유지 계약서" && additionalFiles.length > 0 && (
+ <p className="text-xs text-blue-600 flex items-center">
+ <FileText className="h-3 w-3 text-blue-500 mr-1" />
+ 첨부 서류도 확인해주세요
+ </p>
+ )}
</>
)}
- </Button>
+ </div>
+
+ {/* 동적 버튼 영역 */}
+ <div className="flex items-center space-x-2">
+ {allCompleted ? (
+ // 모든 계약서 완료시
+ <Button
+ className="gap-2 bg-green-600 hover:bg-green-700 transition-colors"
+ onClick={completeAllSigns}
+ >
+ <Trophy className="h-4 w-4" />
+ 모든 서명 완료
+ </Button>
+ ) : currentContractStatus?.status === 'completed' ? (
+ // 현재 계약서가 완료된 경우
+ <Button
+ variant="outline"
+ className="gap-2"
+ onClick={() => {
+ const nextContract = getNextPendingContract();
+ if (nextContract) {
+ setSelectedContract(nextContract);
+ }
+ }}
+ disabled={!getNextPendingContract()}
+ >
+ <ArrowRight className="h-4 w-4" />
+ 다음 계약서
+ </Button>
+ ) : (
+ // 현재 계약서를 서명해야 하는 경우
+ <Button
+ className={`gap-2 transition-colors ${
+ canCompleteCurrentContract
+ ? "bg-blue-600 hover:bg-blue-700"
+ : "bg-gray-400 cursor-not-allowed"
+ }`}
+ onClick={completeSign}
+ disabled={!canCompleteCurrentContract || isSubmitting} // 🔥 조건 수정
+ >
+ {isSubmitting ? (
+ <>
+ <svg className="animate-spin -ml-1 mr-2 h-4 w-4 text-white" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
+ <circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"></circle>
+ <path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
+ </svg>
+ 처리중...
+ </>
+ ) : (
+ <>
+ <FileSignature className="h-4 w-4" />
+ 서명 완료
+ {totalCount > 1 && (
+ <span className="ml-1 text-xs">
+ ({completedCount + 1}/{totalCount})
+ </span>
+ )}
+ </>
+ )}
+ </Button>
+ )}
+ </div>
</div>
</>
) : (
diff --git a/lib/basic-contract/vendor-table/basic-contract-table.tsx b/lib/basic-contract/vendor-table/basic-contract-table.tsx
index f2575024..48298f21 100644
--- a/lib/basic-contract/vendor-table/basic-contract-table.tsx
+++ b/lib/basic-contract/vendor-table/basic-contract-table.tsx
@@ -38,7 +38,6 @@ export function BasicContractsVendorTable({ promises }: BasicTemplateTableProps)
const [{ data, pageCount }] =
React.use(promises)
- console.log(data,"data")
// 안전한 번역 함수 (fallback 포함)
const safeT = React.useCallback((key: string, fallback: string) => {
diff --git a/lib/basic-contract/vendor-table/survey-conditional.ts b/lib/basic-contract/vendor-table/survey-conditional.ts
new file mode 100644
index 00000000..71c2c1ff
--- /dev/null
+++ b/lib/basic-contract/vendor-table/survey-conditional.ts
@@ -0,0 +1,860 @@
+// lib/utils/survey-conditional.ts
+import type { SurveyQuestion, SurveyTemplateWithQuestions } from '../service';
+
+/**
+ * 조건부 질문 처리를 위한 유틸리티 클래스
+ */
+export class ConditionalSurveyHandler {
+ private questions: SurveyQuestion[];
+ private parentChildMap: Map<number, number[]>;
+ private childParentMap: Map<number, { parentId: number; conditionalValue: string }>;
+
+ constructor(template: SurveyTemplateWithQuestions) {
+ this.questions = template.questions;
+ this.parentChildMap = new Map();
+ this.childParentMap = new Map();
+
+ this.buildRelationshipMaps();
+ }
+
+ /**
+ * 부모-자식 관계 맵 구축
+ */
+ private buildRelationshipMaps() {
+ this.questions.forEach(question => {
+ if (question.parentQuestionId && question.conditionalValue) {
+ // 자식 -> 부모 맵핑
+ this.childParentMap.set(question.id, {
+ parentId: question.parentQuestionId,
+ conditionalValue: question.conditionalValue
+ });
+
+ // 부모 -> 자식들 맵핑
+ const existingChildren = this.parentChildMap.get(question.parentQuestionId) || [];
+ this.parentChildMap.set(question.parentQuestionId, [...existingChildren, question.id]);
+ }
+ });
+ }
+
+
+ /**
+ * 질문이 완료되지 않은 이유를 반환 (디버깅용)
+ */
+public getIncompleteReason(question: SurveyQuestion, surveyAnswers: Record<number, any>): string {
+ const answer = surveyAnswers[question.id];
+
+ if (!answer?.answerValue) return '답변이 없음';
+
+ if (answer.answerValue === 'OTHER' && !answer.otherText?.trim()) {
+ return '기타 내용 입력 필요';
+ }
+
+ if (question.hasDetailText && ['YES', '네', 'Y'].includes(answer.answerValue) && !answer.detailText?.trim()) {
+ return '상세 내용 입력 필요';
+ }
+
+ if ((question.hasFileUpload || question.questionType === 'FILE') &&
+ ['YES', '네', 'Y'].includes(answer.answerValue) &&
+ (!answer.files || answer.files.length === 0)) {
+ return '파일 업로드 필요';
+ }
+
+ // ⭐ 조건부 자식 질문들 체크
+ const childQuestions = this.getChildQuestions(question.id);
+ const triggeredChildren = childQuestions.filter(child =>
+ child.conditionalValue === answer.answerValue && child.isRequired
+ );
+
+ for (const childQuestion of triggeredChildren) {
+ if (!this.isQuestionComplete(childQuestion, surveyAnswers)) {
+ return `조건부 질문 "${childQuestion.questionText?.substring(0, 30)}..."이 미완료`;
+ }
+ }
+
+ return '완료됨';
+ }
+
+ /**
+ * 조건부 질문을 위한 단순 완료 체크 (자식 질문 체크 제외)
+ */
+ private isSimpleQuestionComplete(question: SurveyQuestion, surveyAnswers: Record<number, any>): boolean {
+ const answer = surveyAnswers[question.id];
+
+ console.log(`🔍 조건부 질문 단순 완료 체크 [Q${question.questionNumber}]:`, {
+ questionId: question.id,
+ questionNumber: question.questionNumber,
+ isRequired: question.isRequired,
+ hasAnswer: !!answer,
+ answerValue: answer?.answerValue,
+ parentId: question.parentQuestionId,
+ conditionalValue: question.conditionalValue
+ });
+
+ if (!question.isRequired) {
+ console.log(`✅ Q${question.questionNumber}: 선택 질문이므로 완료`);
+ return true;
+ }
+
+ if (!answer?.answerValue) {
+ console.log(`❌ Q${question.questionNumber}: 답변이 없음`);
+ return false;
+ }
+
+ // 1. '기타' 선택 시 추가 입력이 필요한 경우
+ if (answer.answerValue === 'OTHER' && !answer.otherText?.trim()) {
+ console.log(`❌ Q${question.questionNumber}: '기타' 선택했지만 상세 내용 없음`);
+ return false;
+ }
+
+ // 2. 상세 텍스트가 필요한 경우
+ if (question.hasDetailText) {
+ const needsDetailText = ['YES', '네', 'Y', 'true', '1'].includes(answer.answerValue);
+
+ if (needsDetailText && !answer.detailText?.trim()) {
+ console.log(`❌ Q${question.questionNumber}: '${answer.answerValue}' 선택했지만 상세 내용 없음`);
+ return false;
+ }
+ }
+
+ // 3. 파일 업로드가 필요한 경우
+ if (question.hasFileUpload || question.questionType === 'FILE') {
+ const needsFileUpload = ['YES', '네', 'Y', 'true', '1'].includes(answer.answerValue);
+
+ if (needsFileUpload && (!answer.files || answer.files.length === 0)) {
+ console.log(`❌ Q${question.questionNumber}: '${answer.answerValue}' 선택했지만 파일 업로드 없음`);
+ return false;
+ }
+ }
+
+ // 조건부 질문은 자식 질문 체크를 하지 않음 (무한 루프 방지)
+ console.log(`✅ Q${question.questionNumber} 조건부 질문 완료 체크 통과`);
+ return true;
+ }
+
+ private isQuestionCompleteEnhanced(question: SurveyQuestion, surveyAnswers: Record<number, any>): boolean {
+ const answer = surveyAnswers[question.id];
+
+ if (!answer) {
+ console.log(`❌ Q${question.questionNumber}: 답변 객체가 없음`);
+ return false;
+ }
+
+ console.log(`🔍 Q${question.questionNumber} 답변 상세 체크:`, {
+ answerValue: answer.answerValue,
+ detailText: answer.detailText,
+ otherText: answer.otherText,
+ files: answer.files,
+ filesLength: answer.files?.length
+ });
+
+ // 1️⃣ answerValue 체크 (빈 문자열이 아닌 경우)
+ const hasAnswerValue = answer.answerValue && answer.answerValue.trim() !== '';
+
+ // 2️⃣ detailText 체크 (빈 문자열이 아닌 경우)
+ const hasDetailText = answer.detailText && answer.detailText.trim() !== '';
+
+ // 3️⃣ otherText 체크 (OTHER 선택 시)
+ const hasOtherText = answer.answerValue === 'OTHER' ?
+ (answer.otherText && answer.otherText.trim() !== '') : true;
+
+ // 4️⃣ files 체크 (실제 파일이 있는 경우)
+ const hasValidFiles = answer.files && answer.files.length > 0 &&
+ answer.files.every((file: any) => file && typeof file === 'object' &&
+ (file.name || file.size || file.type)); // 빈 객체 {} 제외
+
+ console.log(`📊 Q${question.questionNumber} 완료 조건 체크:`, {
+ hasAnswerValue,
+ hasDetailText,
+ hasOtherText,
+ hasValidFiles,
+ questionType: question.questionType,
+ hasDetailTextRequired: question.hasDetailText,
+ hasFileUploadRequired: question.hasFileUpload || question.questionType === 'FILE'
+ });
+
+ // 🎯 질문 타입별 완료 조건
+ switch (question.questionType) {
+ case 'RADIO':
+ case 'DROPDOWN':
+ // 선택형: answerValue가 있고, OTHER인 경우 otherText도 필요
+ const isSelectComplete = hasAnswerValue && hasOtherText;
+
+ // detailText가 필요한 경우 추가 체크
+ if (question.hasDetailText && isSelectComplete) {
+ return hasDetailText;
+ }
+
+ // 파일 업로드가 필요한 경우 추가 체크
+ if ((question.hasFileUpload || question.questionType === 'FILE') && isSelectComplete) {
+ return hasValidFiles;
+ }
+
+ return isSelectComplete;
+
+ case 'TEXTAREA':
+ // 텍스트 영역: detailText 또는 answerValue 중 하나라도 있으면 됨
+ return hasDetailText || hasAnswerValue;
+
+ case 'FILE':
+ // 파일 업로드: 유효한 파일이 있어야 함
+ return hasValidFiles;
+
+ default:
+ // 기본: answerValue, detailText, 파일 중 하나라도 있으면 완료
+ let isComplete = hasAnswerValue || hasDetailText || hasValidFiles;
+
+ // detailText가 필수인 경우
+ if (question.hasDetailText) {
+ isComplete = isComplete && hasDetailText;
+ }
+
+ // 파일 업로드가 필수인 경우
+ if (question.hasFileUpload) {
+ isComplete = isComplete && hasValidFiles;
+ }
+
+ return isComplete;
+ }
+ }
+
+ // 🎯 개선된 미완료 이유 제공 함수
+private getIncompleteReasonEnhanced(question: SurveyQuestion, surveyAnswers: Record<number, any>): string {
+ const answer = surveyAnswers[question.id];
+
+ if (!answer) {
+ return '답변이 없습니다';
+ }
+
+ const hasAnswerValue = answer.answerValue && answer.answerValue.trim() !== '';
+ const hasDetailText = answer.detailText && answer.detailText.trim() !== '';
+ const hasValidFiles = answer.files && answer.files.length > 0 &&
+ answer.files.every((file: any) => file && typeof file === 'object' &&
+ (file.name || file.size || file.type));
+
+ const reasons: string[] = [];
+
+ switch (question.questionType) {
+ case 'RADIO':
+ case 'DROPDOWN':
+ if (!hasAnswerValue) {
+ reasons.push('선택 필요');
+ } else if (answer.answerValue === 'OTHER' && (!answer.otherText || answer.otherText.trim() === '')) {
+ reasons.push('기타 내용 입력 필요');
+ }
+ break;
+
+ case 'TEXTAREA':
+ if (!hasDetailText && !hasAnswerValue) {
+ reasons.push('텍스트 입력 필요');
+ }
+ break;
+
+ case 'FILE':
+ if (!hasValidFiles) {
+ reasons.push('파일 업로드 필요');
+ }
+ break;
+ }
+
+ // 추가 요구사항 체크
+ if (question.hasDetailText && !hasDetailText) {
+ reasons.push('상세 내용 입력 필요');
+ }
+
+ if ((question.hasFileUpload || question.questionType === 'FILE') && !hasValidFiles) {
+ reasons.push('파일 첨부 필요');
+ }
+
+ return reasons.length > 0 ? reasons.join(', ') : '알 수 없는 이유';
+}
+
+// 🎯 완료 상세 정보 제공 함수
+private getCompletionDetailsEnhanced(question: SurveyQuestion, answer: any): any {
+ if (!answer) return { status: 'no_answer' };
+
+ const hasAnswerValue = answer.answerValue && answer.answerValue.trim() !== '';
+ const hasDetailText = answer.detailText && answer.detailText.trim() !== '';
+ const hasValidFiles = answer.files && answer.files.length > 0 &&
+ answer.files.every((file: any) => file && typeof file === 'object' &&
+ (file.name || file.size || file.type));
+
+ return {
+ status: 'has_answer',
+ hasAnswerValue,
+ hasDetailText,
+ hasValidFiles,
+ answerValue: answer.answerValue,
+ detailTextLength: answer.detailText?.length || 0,
+ filesCount: answer.files?.length || 0,
+ validFilesCount: hasValidFiles ? answer.files.filter((file: any) =>
+ file && typeof file === 'object' && (file.name || file.size || file.type)).length : 0
+ };
+}
+
+ getSimpleProgressStatus(surveyAnswers: Record<number, any>): {
+ visibleQuestions: SurveyQuestion[];
+ totalRequired: number;
+ completedRequired: number;
+ completedQuestionIds: number[];
+ incompleteQuestionIds: number[];
+ progressPercentage: number;
+ debugInfo?: any;
+ } {
+ // 🎯 현재 답변 상태에 따라 표시되는 질문들 계산
+ const visibleQuestions = this.getVisibleQuestions(surveyAnswers);
+
+ console.log('🔍 표시되는 모든 질문들의 상세 정보:', visibleQuestions.map(q => ({
+ id: q.id,
+ questionNumber: q.questionNumber,
+ questionText: q.questionText?.substring(0, 30) + '...',
+ isRequired: q.isRequired,
+ parentQuestionId: q.parentQuestionId,
+ conditionalValue: q.conditionalValue,
+ isConditional: !!q.parentQuestionId,
+ hasAnswer: !!surveyAnswers[q.id]?.answerValue,
+ answerValue: surveyAnswers[q.id]?.answerValue
+ })));
+
+ // 🚨 중요: 트리거된 조건부 질문들을 필수로 처리
+ const requiredQuestions = visibleQuestions.filter(q => {
+ // 기본적으로 필수인 질문들
+ if (q.isRequired) return true;
+
+ // 조건부 질문인 경우, 트리거되었다면 필수로 간주
+ if (q.parentQuestionId && q.conditionalValue) {
+ const parentAnswer = surveyAnswers[q.parentQuestionId];
+ const isTriggered = parentAnswer?.answerValue === q.conditionalValue;
+
+ if (isTriggered) {
+ console.log(`⚡ 조건부 질문 Q${q.questionNumber} (ID: ${q.id})를 필수로 처리 - 트리거됨`);
+ return true;
+ }
+ }
+
+ return false;
+ });
+
+ console.log('📊 필수 질문 필터링 결과:', {
+ 전체질문수: this.questions.length,
+ 표시되는질문수: visibleQuestions.length,
+ 원래필수질문: visibleQuestions.filter(q => q.isRequired).length,
+ 트리거된조건부질문: visibleQuestions.filter(q => {
+ if (!q.parentQuestionId || !q.conditionalValue) return false;
+ const parentAnswer = surveyAnswers[q.parentQuestionId];
+ return parentAnswer?.answerValue === q.conditionalValue;
+ }).length,
+ 최종필수질문: requiredQuestions.length,
+ 현재답변수: Object.keys(surveyAnswers).length,
+ 필수질문들: requiredQuestions.map(q => ({
+ id: q.id,
+ questionNumber: q.questionNumber,
+ isRequired: q.isRequired,
+ isConditional: !!q.parentQuestionId,
+ hasAnswer: !!surveyAnswers[q.id]?.answerValue,
+ 처리방식: q.isRequired ? '원래필수' : '트리거됨'
+ }))
+ });
+
+ const completedQuestionIds: number[] = [];
+ const incompleteQuestionIds: number[] = [];
+ const debugInfo: any = {};
+
+ // 🔍 각 필수 질문의 완료 상태 확인 (개선된 완료 체크 로직)
+ requiredQuestions.forEach(question => {
+ console.log(`🔍 필수 질문 체크 시작: Q${question.questionNumber} (ID: ${question.id}, isRequired: ${question.isRequired})`);
+
+ // 🎯 개선된 완료 체크: 모든 답변 형태를 고려
+ const isComplete = this.isQuestionCompleteEnhanced(question, surveyAnswers);
+
+ console.log(`📊 Q${question.questionNumber} 완료 상태: ${isComplete}`);
+ console.log(`📝 Q${question.questionNumber} 답변 내용:`, surveyAnswers[question.id]);
+
+ // 디버깅 정보 수집
+ debugInfo[question.id] = {
+ questionText: question.questionText,
+ questionNumber: question.questionNumber,
+ isRequired: question.isRequired,
+ isVisible: true,
+ isConditional: !!question.parentQuestionId,
+ parentQuestionId: question.parentQuestionId,
+ conditionalValue: question.conditionalValue,
+ answer: surveyAnswers[question.id],
+ isComplete: isComplete,
+ hasDetailText: question.hasDetailText,
+ hasFileUpload: question.hasFileUpload,
+ childQuestions: this.getChildQuestions(question.id).map(c => ({
+ id: c.id,
+ condition: c.conditionalValue,
+ required: c.isRequired
+ })),
+ incompleteReason: isComplete ? null : this.getIncompleteReasonEnhanced(question, surveyAnswers)
+ };
+
+ if (isComplete) {
+ completedQuestionIds.push(question.id);
+ } else {
+ incompleteQuestionIds.push(question.id);
+ }
+ });
+
+ const progressPercentage = requiredQuestions.length > 0
+ ? (completedQuestionIds.length / requiredQuestions.length) * 100
+ : 100;
+
+ const result = {
+ visibleQuestions,
+ totalRequired: requiredQuestions.length,
+ completedRequired: completedQuestionIds.length,
+ completedQuestionIds,
+ incompleteQuestionIds,
+ progressPercentage: Math.min(100, progressPercentage)
+ };
+
+ // 개발 환경에서만 디버깅 정보 포함
+ if (process.env.NODE_ENV === 'development') {
+ (result as any).debugInfo = debugInfo;
+
+ // 📋 상세한 진행 상황 로그
+ console.log('📋 최종 진행 상황:', {
+ 총필수질문: requiredQuestions.length,
+ 완료된질문: completedQuestionIds.length,
+ 미완료질문: incompleteQuestionIds.length,
+ 진행률: `${Math.round(progressPercentage)}%`,
+ 기본질문: visibleQuestions.filter(q => !q.parentQuestionId).length,
+ 조건부질문: visibleQuestions.filter(q => q.parentQuestionId).length,
+ 완료된기본질문: completedQuestionIds.filter(id => !visibleQuestions.find(q => q.id === id)?.parentQuestionId).length,
+ 완료된조건부질문: completedQuestionIds.filter(id => !!visibleQuestions.find(q => q.id === id)?.parentQuestionId).length,
+ 필수질문상세: requiredQuestions.map(q => ({
+ id: q.id,
+ questionNumber: q.questionNumber,
+ isRequired: q.isRequired,
+ isConditional: !!q.parentQuestionId,
+ isComplete: completedQuestionIds.includes(q.id)
+ }))
+ });
+
+ // 🔍 미완료 질문들의 구체적 이유
+ if (incompleteQuestionIds.length > 0) {
+ console.log('🔍 미완료 질문들:', incompleteQuestionIds.map(id => ({
+ id,
+ questionNumber: debugInfo[id]?.questionNumber,
+ text: debugInfo[id]?.questionText?.substring(0, 50) + '...',
+ reason: debugInfo[id]?.incompleteReason,
+ isConditional: !!debugInfo[id]?.parentQuestionId
+ })));
+ }
+
+ // ⚡ 조건부 질문 활성화 및 완료 현황
+ const conditionalQuestions = visibleQuestions.filter(q => q.parentQuestionId);
+ if (conditionalQuestions.length > 0) {
+ console.log('⚡ 조건부 질문 상세 현황:', conditionalQuestions.map(q => ({
+ id: q.id,
+ questionNumber: q.questionNumber,
+ isRequired: q.isRequired,
+ parentId: q.parentQuestionId,
+ condition: q.conditionalValue,
+ parentAnswer: surveyAnswers[q.parentQuestionId!]?.answerValue,
+ isTriggered: surveyAnswers[q.parentQuestionId!]?.answerValue === q.conditionalValue,
+ hasAnswer: !!surveyAnswers[q.id]?.answerValue,
+ answerValue: surveyAnswers[q.id]?.answerValue,
+ detailText: surveyAnswers[q.id]?.detailText,
+ files: surveyAnswers[q.id]?.files,
+ isComplete: debugInfo[q.id]?.isComplete,
+ isIncludedInRequired: requiredQuestions.some(rq => rq.id === q.id),
+ completionDetails: this.getCompletionDetailsEnhanced(q, surveyAnswers[q.id])
+ })));
+ }
+ }
+
+ return result;
+ }
+
+ /**
+ * 전체 설문조사의 진행 상태를 계산 (조건부 질문 고려)
+ * 표시되지 않는 질문들은 자동으로 완료된 것으로 간주하여 프로그레스가 역행하지 않도록 함
+ */
+getOverallProgressStatus(surveyAnswers: Record<number, any>): {
+ totalQuestions: number;
+ completedQuestions: number;
+ visibleQuestions: number;
+ requiredVisible: number;
+ completedRequired: number;
+ completedQuestionIds: number[];
+ incompleteQuestionIds: number[];
+ progressPercentage: number;
+ } {
+ const allQuestions = this.questions;
+ const visibleQuestions = this.getVisibleQuestions(surveyAnswers);
+ const requiredQuestions = allQuestions.filter(q => q.isRequired);
+
+ const completedQuestionIds: number[] = [];
+ const incompleteQuestionIds: number[] = [];
+
+ let totalCompleted = 0;
+
+ allQuestions.forEach(question => {
+ const isVisible = visibleQuestions.some(vq => vq.id === question.id);
+
+ if (!isVisible && question.isRequired) {
+ // 조건에 맞지 않아 숨겨진 필수 질문들은 자동 완료로 간주
+ totalCompleted++;
+ completedQuestionIds.push(question.id);
+ } else if (isVisible && question.isRequired) {
+ // 표시되는 필수 질문들은 실제 완료 여부 체크
+ if (this.isQuestionComplete(question, surveyAnswers)) {
+ totalCompleted++;
+ completedQuestionIds.push(question.id);
+ } else {
+ incompleteQuestionIds.push(question.id);
+ }
+ } else if (!question.isRequired) {
+ // 선택 질문들은 완료된 것으로 간주
+ totalCompleted++;
+ }
+ });
+
+ const progressPercentage = requiredQuestions.length > 0
+ ? (totalCompleted / requiredQuestions.length) * 100
+ : 100;
+
+ return {
+ totalQuestions: allQuestions.length,
+ completedQuestions: totalCompleted,
+ visibleQuestions: visibleQuestions.length,
+ requiredVisible: visibleQuestions.filter(q => q.isRequired).length,
+ completedRequired: completedQuestionIds.length,
+ completedQuestionIds,
+ incompleteQuestionIds,
+ progressPercentage: Math.min(100, progressPercentage)
+ };
+ }
+
+ /**
+ * 현재 표시되는 질문들만의 완료 상태 (기존 메서드 유지)
+ */
+ getVisibleRequiredStatus(surveyAnswers: Record<number, any>): {
+ total: number;
+ completed: number;
+ completedQuestionIds: number[];
+ incompleteQuestionIds: number[];
+ } {
+ const visibleQuestions = this.getVisibleQuestions(surveyAnswers);
+ const requiredQuestions = visibleQuestions.filter(q => q.isRequired);
+
+ const completedQuestionIds: number[] = [];
+ const incompleteQuestionIds: number[] = [];
+
+ requiredQuestions.forEach(question => {
+ if (this.isQuestionComplete(question, surveyAnswers)) {
+ completedQuestionIds.push(question.id);
+ } else {
+ incompleteQuestionIds.push(question.id);
+ }
+ });
+
+ return {
+ total: requiredQuestions.length,
+ completed: completedQuestionIds.length,
+ completedQuestionIds,
+ incompleteQuestionIds
+ };
+ }
+ /**
+ * 현재 답변 상태에 따라 표시되어야 할 질문들 필터링
+ */
+ getVisibleQuestions(surveyAnswers: Record<number, any>): SurveyQuestion[] {
+ return this.questions.filter(question => this.shouldShowQuestion(question, surveyAnswers));
+ }
+
+ /**
+ * 특정 질문이 현재 표시되어야 하는지 판단
+ */
+ private shouldShowQuestion(question: SurveyQuestion, surveyAnswers: Record<number, any>): boolean {
+ // 최상위 질문 (부모가 없는 경우)
+ if (!question.parentQuestionId || !question.conditionalValue) {
+ return true;
+ }
+
+ const parentAnswer = surveyAnswers[question.parentQuestionId];
+ if (!parentAnswer || !parentAnswer.answerValue) {
+ return false;
+ }
+
+ // 부모 질문의 답변이 조건값과 일치하는지 확인
+ return parentAnswer.answerValue === question.conditionalValue;
+ }
+
+ /**
+ * 부모 질문의 답변이 변경될 때 영향받는 자식 질문들의 답변 초기화
+ */
+ clearAffectedChildAnswers(
+ parentQuestionId: number,
+ newParentValue: string,
+ currentAnswers: Record<number, any>
+ ): Record<number, any> {
+ const updatedAnswers = { ...currentAnswers };
+ const childQuestionIds = this.parentChildMap.get(parentQuestionId) || [];
+
+ console.log(`🧹 질문 ${parentQuestionId}의 답변 변경으로 인한 자식 질문 정리:`, {
+ parentQuestionId,
+ newParentValue,
+ childQuestionIds,
+ 자식질문수: childQuestionIds.length
+ });
+
+ childQuestionIds.forEach(childId => {
+ const childQuestion = this.questions.find(q => q.id === childId);
+ if (!childQuestion) return;
+
+ console.log(`🔍 자식 질문 ${childId} 체크:`, {
+ childId,
+ questionNumber: childQuestion.questionNumber,
+ conditionalValue: childQuestion.conditionalValue,
+ newParentValue,
+ shouldKeep: childQuestion.conditionalValue === newParentValue,
+ currentAnswer: updatedAnswers[childId]?.answerValue
+ });
+
+ // 새로운 부모 값이 자식의 조건과 맞지 않으면 자식 답변 삭제
+ if (childQuestion.conditionalValue !== newParentValue) {
+ console.log(`🗑️ 자식 질문 Q${childQuestion.questionNumber} 답변 초기화 (조건 불일치)`);
+ delete updatedAnswers[childId];
+
+ // 재귀적으로 손자 질문들도 정리
+ const grandChildAnswers = this.clearAffectedChildAnswers(childId, '', updatedAnswers);
+ Object.assign(updatedAnswers, grandChildAnswers);
+ } else {
+ console.log(`✅ 자식 질문 Q${childQuestion.questionNumber} 유지 (조건 일치)`);
+ }
+ });
+
+ const clearedCount = childQuestionIds.filter(childId => !updatedAnswers[childId]).length;
+ const keptCount = childQuestionIds.filter(childId => !!updatedAnswers[childId]).length;
+
+ console.log(`📊 자식 질문 정리 완료:`, {
+ parentQuestionId,
+ 총자식질문: childQuestionIds.length,
+ 초기화된질문: clearedCount,
+ 유지된질문: keptCount
+ });
+
+ return updatedAnswers;
+ }
+
+ /**
+ * 표시되는 질문들 중 필수 질문의 완료 여부 체크
+ */
+ getRequiredQuestionsStatus(surveyAnswers: Record<number, any>): {
+ total: number;
+ completed: number;
+ completedQuestionIds: number[];
+ incompleteQuestionIds: number[];
+ } {
+ const status = this.getSimpleProgressStatus(surveyAnswers);
+ return {
+ total: status.totalRequired,
+ completed: status.completedRequired,
+ completedQuestionIds: status.completedQuestionIds,
+ incompleteQuestionIds: status.incompleteQuestionIds
+ };
+ }
+
+ /**
+ * 개별 질문의 완료 여부 체크
+ */
+ private isQuestionComplete(question: SurveyQuestion, surveyAnswers: Record<number, any>): boolean {
+ const answer = surveyAnswers[question.id];
+
+ // 🔍 상세한 완료 체크 로깅
+ const logData = {
+ questionId: question.id,
+ questionNumber: question.questionNumber,
+ questionText: question.questionText?.substring(0, 30) + '...',
+ isRequired: question.isRequired,
+ hasAnswer: !!answer,
+ answerValue: answer?.answerValue,
+ hasDetailText: question.hasDetailText,
+ hasFileUpload: question.hasFileUpload,
+ isConditional: !!question.parentQuestionId,
+ parentId: question.parentQuestionId,
+ conditionalValue: question.conditionalValue
+ };
+
+ console.log(`🔍 질문 완료 체크 [Q${question.questionNumber}]:`, logData);
+
+ if (!question.isRequired) {
+ console.log(`✅ Q${question.questionNumber}: 선택 질문이므로 완료`);
+ return true;
+ }
+
+ if (!answer?.answerValue) {
+ console.log(`❌ Q${question.questionNumber}: 답변이 없음`);
+ return false;
+ }
+
+ // 1. '기타' 선택 시 추가 입력이 필요한 경우
+ if (answer.answerValue === 'OTHER' && !answer.otherText?.trim()) {
+ console.log(`❌ Q${question.questionNumber}: '기타' 선택했지만 상세 내용 없음`);
+ return false;
+ }
+
+ // 2. 상세 텍스트가 필요한 경우
+ if (question.hasDetailText) {
+ const needsDetailText = ['YES', '네', 'Y', 'true', '1'].includes(answer.answerValue);
+
+ console.log(`📝 Q${question.questionNumber} 상세텍스트 체크:`, {
+ hasDetailText: question.hasDetailText,
+ answerValue: answer.answerValue,
+ needsDetailText,
+ detailText: answer.detailText?.length || 0,
+ detailTextExists: !!answer.detailText?.trim()
+ });
+
+ if (needsDetailText && !answer.detailText?.trim()) {
+ console.log(`❌ Q${question.questionNumber}: '${answer.answerValue}' 선택했지만 상세 내용 없음`);
+ return false;
+ }
+ }
+
+ // 3. 파일 업로드가 필요한 경우
+ if (question.hasFileUpload || question.questionType === 'FILE') {
+ const needsFileUpload = ['YES', '네', 'Y', 'true', '1'].includes(answer.answerValue);
+
+ console.log(`📁 Q${question.questionNumber} 파일업로드 체크:`, {
+ hasFileUpload: question.hasFileUpload,
+ questionType: question.questionType,
+ answerValue: answer.answerValue,
+ needsFileUpload,
+ filesCount: answer.files?.length || 0,
+ hasFiles: !!answer.files && answer.files.length > 0
+ });
+
+ if (needsFileUpload && (!answer.files || answer.files.length === 0)) {
+ console.log(`❌ Q${question.questionNumber}: '${answer.answerValue}' 선택했지만 파일 업로드 없음`);
+ return false;
+ }
+ }
+
+ // 4. ⭐ 핵심: 조건부 자식 질문들 체크
+ const childQuestions = this.getChildQuestions(question.id);
+
+ if (childQuestions.length > 0) {
+ console.log(`🔗 Q${question.questionNumber} 부모 질문 - 자식 질문들:`,
+ childQuestions.map(c => ({
+ id: c.id,
+ questionNumber: c.questionNumber,
+ condition: c.conditionalValue,
+ required: c.isRequired,
+ text: c.questionText?.substring(0, 20) + '...'
+ }))
+ );
+
+ // 현재 답변으로 트리거되는 자식 질문들 찾기
+ const triggeredChildren = childQuestions.filter(child =>
+ child.conditionalValue === answer.answerValue
+ );
+
+ console.log(`🎯 Q${question.questionNumber} 답변 '${answer.answerValue}'로 트리거된 자식들:`,
+ triggeredChildren.map(c => ({
+ id: c.id,
+ questionNumber: c.questionNumber,
+ required: c.isRequired,
+ text: c.questionText?.substring(0, 30) + '...'
+ }))
+ );
+
+ // 트리거된 필수 자식 질문들이 모두 완료되었는지 확인
+ for (const childQuestion of triggeredChildren) {
+ if (childQuestion.isRequired) {
+ console.log(`🔄 자식 질문 Q${childQuestion.questionNumber} 완료 체크 시작...`);
+ const childComplete = this.isQuestionComplete(childQuestion, surveyAnswers);
+ console.log(`📊 자식 질문 Q${childQuestion.questionNumber} 완료 상태: ${childComplete}`);
+
+ if (!childComplete) {
+ console.log(`❌ 부모 Q${question.questionNumber}의 자식 Q${childQuestion.questionNumber} 미완료`);
+ return false;
+ }
+ }
+ }
+
+ if (triggeredChildren.filter(c => c.isRequired).length > 0) {
+ console.log(`✅ Q${question.questionNumber}의 모든 필수 조건부 자식 질문들 완료됨`);
+ }
+ }
+
+ console.log(`✅ Q${question.questionNumber} 완료 체크 통과`);
+ return true;
+ }
+
+ /**
+ * 전체 설문조사 완료 여부
+ */
+ isSurveyComplete(surveyAnswers: Record<number, any>): boolean {
+ const status = this.getRequiredQuestionsStatus(surveyAnswers);
+ return status.total === status.completed;
+ }
+
+ /**
+ * 디버깅을 위한 관계 맵 출력
+ */
+ debugRelationships(): void {
+ console.log('=== 조건부 질문 관계 맵 ===');
+ console.log('부모 -> 자식:', Array.from(this.parentChildMap.entries()));
+ console.log('자식 -> 부모:', Array.from(this.childParentMap.entries()));
+ }
+
+ /**
+ * 특정 질문의 자식 질문들 가져오기
+ */
+ getChildQuestions(parentQuestionId: number): SurveyQuestion[] {
+ const childIds = this.parentChildMap.get(parentQuestionId) || [];
+ return this.questions.filter(q => childIds.includes(q.id));
+ }
+
+ /**
+ * 질문 트리 구조 생성 (중첩된 조건부 질문 처리)
+ */
+ buildQuestionTree(): QuestionNode[] {
+ const rootQuestions = this.questions.filter(q => !q.parentQuestionId);
+ return rootQuestions.map(q => this.buildQuestionNode(q));
+ }
+
+ private buildQuestionNode(question: SurveyQuestion): QuestionNode {
+ const childQuestions = this.getChildQuestions(question.id);
+ return {
+ question,
+ children: childQuestions.map(child => this.buildQuestionNode(child))
+ };
+ }
+}
+
+/**
+ * 질문 트리 노드 인터페이스
+ */
+export interface QuestionNode {
+ question: SurveyQuestion;
+ children: QuestionNode[];
+}
+
+/**
+ * React Hook으로 조건부 설문조사 상태 관리
+ */
+import React from 'react';
+
+export function useConditionalSurvey(template: SurveyTemplateWithQuestions | null) {
+ const [handler, setHandler] = React.useState<ConditionalSurveyHandler | null>(null);
+
+ React.useEffect(() => {
+ if (template) {
+ const newHandler = new ConditionalSurveyHandler(template);
+ setHandler(newHandler);
+
+ // 개발 환경에서 디버깅 정보 출력
+ if (process.env.NODE_ENV === 'development') {
+ newHandler.debugRelationships();
+ }
+ }
+ }, [template]);
+
+ return handler;
+} \ No newline at end of file