diff options
| author | dujinkim <dujin.kim@dtsolution.co.kr> | 2025-08-26 01:17:56 +0000 |
|---|---|---|
| committer | dujinkim <dujin.kim@dtsolution.co.kr> | 2025-08-26 01:17:56 +0000 |
| commit | 12e936c0b45ffa1c8f3c02ff77961212767be9a7 (patch) | |
| tree | 34f31b9a64c6d30e187c1114530c4d47b95d30a9 /lib | |
| parent | 83f67ed333f0237b434a41d1eceef417c0d48313 (diff) | |
(대표님) 가입, 기본계약, 벤더
(최겸) 기술영업 아이템 관련
Diffstat (limited to 'lib')
24 files changed, 4615 insertions, 1716 deletions
diff --git a/lib/basic-contract/service.ts b/lib/basic-contract/service.ts index 64a50d14..58463f16 100644 --- a/lib/basic-contract/service.ts +++ b/lib/basic-contract/service.ts @@ -11,9 +11,18 @@ import { BasicContractTemplate,
basicContractTemplates,
basicContractView,
+ complianceQuestionOptions,
+ complianceQuestions,
+ complianceResponseAnswers,
+ complianceResponseFiles,
+ complianceResponses,
+ complianceSurveyTemplates,
vendorAttachments,
vendors,
type BasicContractTemplate as DBBasicContractTemplate,
+ type NewComplianceResponse,
+ type NewComplianceResponseAnswer,
+ type NewComplianceResponseFile
} from "@/db/schema";
import {
@@ -1172,3 +1181,500 @@ export async function getVendorAttachments(vendorId: number) { };
}
}
+
+// 설문조사 템플릿 전체 데이터 타입
+export interface SurveyTemplateWithQuestions {
+ id: number;
+ name: string;
+ description: string | null;
+ version: string;
+ questions: SurveyQuestion[];
+}
+
+export interface SurveyQuestion {
+ id: number;
+ questionNumber: string;
+ questionText: string;
+ questionType: string;
+ isRequired: boolean;
+ hasDetailText: boolean;
+ hasFileUpload: boolean;
+ parentQuestionId: number | null;
+ conditionalValue: string | null;
+ displayOrder: number;
+ options: SurveyQuestionOption[];
+}
+
+export interface SurveyQuestionOption {
+ id: number;
+ optionValue: string;
+ optionText: string;
+ allowsOtherInput: boolean;
+ displayOrder: number;
+}
+
+/**
+ * 활성화된 첫 번째 설문조사 템플릿과 관련 데이터를 모두 가져오기
+ */
+export async function getActiveSurveyTemplate(): Promise<SurveyTemplateWithQuestions | null> {
+ try {
+ // 1. 활성화된 첫 번째 템플릿 가져오기
+ const template = await db
+ .select()
+ .from(complianceSurveyTemplates)
+ .where(eq(complianceSurveyTemplates.isActive, true))
+ .orderBy(complianceSurveyTemplates.id)
+ .limit(1);
+
+ if (!template || template.length === 0) {
+ console.log('활성화된 설문조사 템플릿이 없습니다.');
+ return null;
+ }
+
+ const templateData = template[0];
+
+ // 2. 해당 템플릿의 모든 질문 가져오기 (displayOrder 순)
+ const questions = await db
+ .select()
+ .from(complianceQuestions)
+ .where(eq(complianceQuestions.templateId, templateData.id))
+ .orderBy(asc(complianceQuestions.displayOrder));
+
+ // 3. 각 질문의 옵션들 가져오기
+ const questionIds = questions.map(q => q.id);
+ const allOptions = questionIds.length > 0
+ ? await db
+ .select()
+ .from(complianceQuestionOptions)
+ .where(inArray(complianceQuestionOptions.questionId, questionIds))
+ .orderBy(
+ complianceQuestionOptions.questionId,
+ asc(complianceQuestionOptions.displayOrder)
+ )
+ : [];
+
+
+ // 4. 질문별로 옵션들 그룹화
+ const optionsByQuestionId = allOptions.reduce((acc, option) => {
+ if (!acc[option.questionId]) {
+ acc[option.questionId] = [];
+ }
+ acc[option.questionId].push({
+ id: option.id,
+ optionValue: option.optionValue,
+ optionText: option.optionText,
+ allowsOtherInput: option.allowsOtherInput,
+ displayOrder: option.displayOrder,
+ });
+ return acc;
+ }, {} as Record<number, SurveyQuestionOption[]>);
+
+ // 5. 최종 데이터 구성
+ const questionsWithOptions: SurveyQuestion[] = questions.map(question => ({
+ id: question.id,
+ questionNumber: question.questionNumber,
+ questionText: question.questionText,
+ questionType: question.questionType,
+ isRequired: question.isRequired,
+ hasDetailText: question.hasDetailText,
+ hasFileUpload: question.hasFileUpload,
+ parentQuestionId: question.parentQuestionId,
+ conditionalValue: question.conditionalValue,
+ displayOrder: question.displayOrder,
+ options: optionsByQuestionId[question.id] || [],
+ }));
+
+ return {
+ id: templateData.id,
+ name: templateData.name,
+ description: templateData.description,
+ version: templateData.version,
+ questions: questionsWithOptions,
+ };
+
+ } catch (error) {
+ console.error('설문조사 템플릿 로드 실패:', error);
+ return null;
+ }
+}
+
+/**
+ * 특정 템플릿 ID로 설문조사 템플릿 가져오기
+ */
+export async function getSurveyTemplateById(templateId: number): Promise<SurveyTemplateWithQuestions | null> {
+ try {
+ const template = await db
+ .select()
+ .from(complianceSurveyTemplates)
+ .where(eq(complianceSurveyTemplates.id, templateId))
+ .limit(1);
+
+ if (!template || template.length === 0) {
+ return null;
+ }
+
+ const templateData = template[0];
+
+ const questions = await db
+ .select()
+ .from(complianceQuestions)
+ .where(eq(complianceQuestions.templateId, templateId))
+ .orderBy(asc(complianceQuestions.displayOrder));
+
+ const questionIds = questions.map(q => q.id);
+ const allOptions = questionIds.length > 0
+ ? await db
+ .select()
+ .from(complianceQuestionOptions)
+ .where(
+ complianceQuestionOptions.questionId.in ?
+ complianceQuestionOptions.questionId.in(questionIds) :
+ eq(complianceQuestionOptions.questionId, questionIds[0])
+ )
+ .orderBy(
+ complianceQuestionOptions.questionId,
+ asc(complianceQuestionOptions.displayOrder)
+ )
+ : [];
+
+ const optionsByQuestionId = allOptions.reduce((acc, option) => {
+ if (!acc[option.questionId]) {
+ acc[option.questionId] = [];
+ }
+ acc[option.questionId].push({
+ id: option.id,
+ optionValue: option.optionValue,
+ optionText: option.optionText,
+ allowsOtherInput: option.allowsOtherInput,
+ displayOrder: option.displayOrder,
+ });
+ return acc;
+ }, {} as Record<number, SurveyQuestionOption[]>);
+
+ const questionsWithOptions: SurveyQuestion[] = questions.map(question => ({
+ id: question.id,
+ questionNumber: question.questionNumber,
+ questionText: question.questionText,
+ questionType: question.questionType,
+ isRequired: question.isRequired,
+ hasDetailText: question.hasDetailText,
+ hasFileUpload: question.hasFileUpload,
+ parentQuestionId: question.parentQuestionId,
+ conditionalValue: question.conditionalValue,
+ displayOrder: question.displayOrder,
+ options: optionsByQuestionId[question.id] || [],
+ }));
+
+ return {
+ id: templateData.id,
+ name: templateData.name,
+ description: templateData.description,
+ version: templateData.version,
+ questions: questionsWithOptions,
+ };
+
+ } catch (error) {
+ console.error('설문조사 템플릿 로드 실패:', error);
+ return null;
+ }
+}
+
+
+// 설문 답변 데이터 타입 정의
+export interface SurveyAnswerData {
+ questionId: number;
+ answerValue?: string;
+ detailText?: string;
+ otherText?: string;
+ files?: File[];
+}
+
+// 설문조사 완료 요청 데이터 타입
+export interface CompleteSurveyRequest {
+ contractId: number;
+ templateId: number;
+ answers: SurveyAnswerData[];
+ progressStatus?: any; // 진행 상태 정보 (옵션)
+}
+
+// 서버 액션: 설문조사 완료 처리
+export async function completeSurvey(data: CompleteSurveyRequest) {
+ try {
+ console.log('🚀 설문조사 완료 처리 시작:', {
+ contractId: data.contractId,
+ templateId: data.templateId,
+ answersCount: data.answers?.length || 0
+ });
+
+ // 입력 검증
+ if (!data.contractId || !data.templateId || !data.answers?.length) {
+ throw new Error('필수 데이터가 누락되었습니다.');
+ }
+
+ // 트랜잭션으로 처리
+ const result = await db.transaction(async (tx) => {
+ // 1. complianceResponses 테이블 upsert
+ console.log('📋 complianceResponses 처리 중...');
+
+ // 기존 응답 확인
+ const existingResponse = await tx
+ .select()
+ .from(complianceResponses)
+ .where(
+ and(
+ eq(complianceResponses.basicContractId, data.contractId),
+ eq(complianceResponses.templateId, data.templateId)
+ )
+ )
+ .limit(1);
+
+ let responseId: number;
+
+ if (existingResponse.length > 0) {
+ // 기존 응답 업데이트
+ const updateData = {
+ status: 'COMPLETED' as const,
+ completedAt: new Date(),
+ updatedAt: new Date()
+ };
+
+ await tx
+ .update(complianceResponses)
+ .set(updateData)
+ .where(eq(complianceResponses.id, existingResponse[0].id));
+
+ responseId = existingResponse[0].id;
+ console.log(`✅ 기존 응답 업데이트 완료: ID ${responseId}`);
+ } else {
+ // 새 응답 생성
+ const newResponse: NewComplianceResponse = {
+ basicContractId: data.contractId,
+ templateId: data.templateId,
+ status: 'COMPLETED',
+ completedAt: new Date()
+ };
+
+ const insertResult = await tx
+ .insert(complianceResponses)
+ .values(newResponse)
+ .returning({ id: complianceResponses.id });
+
+ responseId = insertResult[0].id;
+ console.log(`✅ 새 응답 생성 완료: ID ${responseId}`);
+ }
+
+ // 2. 기존 답변들 삭제 (파일도 함께 삭제됨 - CASCADE 설정 필요)
+ console.log('🗑️ 기존 답변들 삭제 중...');
+
+ // 먼저 기존 답변에 연결된 파일들 삭제
+ const existingAnswers = await tx
+ .select({ id: complianceResponseAnswers.id })
+ .from(complianceResponseAnswers)
+ .where(eq(complianceResponseAnswers.responseId, responseId));
+
+ if (existingAnswers.length > 0) {
+ const answerIds = existingAnswers.map(a => a.id);
+
+ // 파일들 먼저 삭제
+ for (const answerId of answerIds) {
+ await tx
+ .delete(complianceResponseFiles)
+ .where(eq(complianceResponseFiles.answerId, answerId));
+ }
+
+ // 답변들 삭제
+ await tx
+ .delete(complianceResponseAnswers)
+ .where(eq(complianceResponseAnswers.responseId, responseId));
+ }
+
+ // 3. 새로운 답변들 생성
+ console.log('📝 새로운 답변들 생성 중...');
+ const createdAnswers: { questionId: number; answerId: number; files?: File[] }[] = [];
+
+ for (const answer of data.answers) {
+ // 빈 답변은 스킵 (선택적 질문의 경우)
+ if (!answer.answerValue && !answer.detailText && (!answer.files || answer.files.length === 0)) {
+ continue;
+ }
+
+ const newAnswer: NewComplianceResponseAnswer = {
+ responseId,
+ questionId: answer.questionId,
+ answerValue: answer.answerValue || null,
+ detailText: answer.detailText || null,
+ otherText: answer.otherText || null,
+ // percentageValue는 필요시 추가 처리
+ };
+
+ const answerResult = await tx
+ .insert(complianceResponseAnswers)
+ .values(newAnswer)
+ .returning({ id: complianceResponseAnswers.id });
+
+ const answerId = answerResult[0].id;
+
+ createdAnswers.push({
+ questionId: answer.questionId,
+ answerId,
+ files: answer.files
+ });
+
+ console.log(`✅ 답변 생성: 질문 ${answer.questionId} -> 답변 ID ${answerId}`);
+ }
+
+ // 4. 파일 업로드 처리 (실제 파일 저장은 별도 로직 필요)
+ console.log('📎 파일 업로드 처리 중...');
+
+ for (const answerWithFiles of createdAnswers) {
+ if (answerWithFiles.files && answerWithFiles.files.length > 0) {
+ for (const file of answerWithFiles.files) {
+ // TODO: 실제 파일 저장 로직 구현 필요
+ // 현재는 파일 메타데이터만 저장
+
+
+ // 파일 저장 경로 생성 (예시)
+ const fileName = file.name;
+ const filePath = `/uploads/compliance/${data.contractId}/${responseId}/${answerWithFiles.answerId}/${fileName}`;
+
+ const fileUpload = await saveFile({file,filePath })
+
+ const newFile: NewComplianceResponseFile = {
+ answerId: answerWithFiles.answerId,
+ fileName,
+ filePath,
+ fileSize: file.size,
+ mimeType: file.type || 'application/octet-stream'
+ };
+
+ await tx
+ .insert(complianceResponseFiles)
+ .values(newFile);
+
+ console.log(`📎 파일 메타데이터 저장: ${fileName}`);
+ }
+ }
+ }
+
+ return {
+ responseId,
+ answersCount: createdAnswers.length,
+ success: true
+ };
+ });
+
+ console.log('🎉 설문조사 완료 처리 성공:', result);
+
+
+ return {
+ success: true,
+ message: '설문조사가 성공적으로 완료되었습니다.',
+ data: result
+ };
+
+ } catch (error) {
+ console.error('❌ 설문조사 완료 처리 실패:', error);
+
+ return {
+ success: false,
+ message: error instanceof Error ? error.message : '설문조사 저장에 실패했습니다.',
+ data: null
+ };
+ }
+}
+
+// 설문조사 응답 조회 서버 액션
+export async function getSurveyResponse(contractId: number, templateId: number) {
+ try {
+ const response = await db
+ .select()
+ .from(complianceResponses)
+ .where(
+ and(
+ eq(complianceResponses.basicContractId, contractId),
+ eq(complianceResponses.templateId, templateId)
+ )
+ )
+ .limit(1);
+
+ if (response.length === 0) {
+ return { success: true, data: null };
+ }
+
+ // 답변들과 파일들도 함께 조회
+ const answers = await db
+ .select({
+ id: complianceResponseAnswers.id,
+ questionId: complianceResponseAnswers.questionId,
+ answerValue: complianceResponseAnswers.answerValue,
+ detailText: complianceResponseAnswers.detailText,
+ otherText: complianceResponseAnswers.otherText,
+ percentageValue: complianceResponseAnswers.percentageValue,
+ })
+ .from(complianceResponseAnswers)
+ .where(eq(complianceResponseAnswers.responseId, response[0].id));
+
+ // 각 답변의 파일들 조회
+ const answersWithFiles = await Promise.all(
+ answers.map(async (answer) => {
+ const files = await db
+ .select()
+ .from(complianceResponseFiles)
+ .where(eq(complianceResponseFiles.answerId, answer.id));
+
+ return {
+ ...answer,
+ files
+ };
+ })
+ );
+
+ return {
+ success: true,
+ data: {
+ response: response[0],
+ answers: answersWithFiles
+ }
+ };
+
+ } catch (error) {
+ console.error('❌ 설문조사 응답 조회 실패:', error);
+
+ return {
+ success: false,
+ message: error instanceof Error ? error.message : '설문조사 응답 조회에 실패했습니다.',
+ data: null
+ };
+ }
+}
+
+// 파일 업로드를 위한 별도 서버 액션 (실제 파일 저장 로직)
+export async function uploadSurveyFile(file: File, contractId: number, answerId: number) {
+ try {
+ // TODO: 실제 파일 저장 구현
+ // 예: AWS S3, 로컬 파일시스템, 등등
+
+ // 현재는 예시 구현
+ const fileName = `${Date.now()}-${file.name}`;
+ const filePath = `/uploads/compliance/${contractId}/${answerId}/${fileName}`;
+
+ // 실제로는 여기서 파일을 물리적으로 저장해야 함
+ // const savedPath = await saveFileToStorage(file, filePath);
+
+ return {
+ success: true,
+ filePath,
+ fileName: file.name,
+ fileSize: file.size,
+ mimeType: file.type
+ };
+
+ } catch (error) {
+ console.error('❌ 파일 업로드 실패:', error);
+
+ return {
+ success: false,
+ message: error instanceof Error ? error.message : '파일 업로드에 실패했습니다.'
+ };
+ }
+}
\ No newline at end of file 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 diff --git a/lib/basic-contract/viewer/basic-contract-sign-viewer.tsx b/lib/basic-contract/viewer/basic-contract-sign-viewer.tsx index b92df089..fbf36738 100644 --- a/lib/basic-contract/viewer/basic-contract-sign-viewer.tsx +++ b/lib/basic-contract/viewer/basic-contract-sign-viewer.tsx @@ -1,11 +1,13 @@ "use client"; import React, { -useState, -useEffect, -useRef, -SetStateAction, -Dispatch, + useState, + useEffect, + useRef, + SetStateAction, + Dispatch, + useMemo, + useCallback, } from "react"; import { WebViewerInstance } from "@pdftron/webviewer"; import { Loader2, FileText, ClipboardList, AlertTriangle, FileSignature, Target, CheckCircle2 } from "lucide-react"; @@ -15,43 +17,55 @@ import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; import { -Dialog, -DialogContent, -DialogHeader, -DialogTitle, -DialogDescription, -DialogFooter, + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + DialogDescription, + DialogFooter, } from "@/components/ui/dialog"; import { Label } from "@/components/ui/label"; import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group"; -import { Checkbox } from "@/components/ui/checkbox"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; import { Input } from "@/components/ui/input"; import { Textarea } from "@/components/ui/textarea"; import { Upload } from "lucide-react"; - - +import { CompleteSurveyRequest, SurveyAnswerData, completeSurvey, getActiveSurveyTemplate, type SurveyTemplateWithQuestions } from '../service'; +import { ConditionalSurveyHandler, useConditionalSurvey } from '../vendor-table/survey-conditional'; +import { useForm, useWatch, Controller } from "react-hook-form"; interface FileInfo { -path: string; -name: string; -type: 'main' | 'attachment' | 'survey'; + path: string; + name: string; + type: 'main' | 'attachment' | 'survey'; } interface BasicContractSignViewerProps { -contractId?: number; -filePath?: string; -additionalFiles?: FileInfo[]; -templateName?: string; -isOpen?: boolean; -onClose?: () => void; -onSign?: (documentData: ArrayBuffer, surveyData?: any) => Promise<void>; -instance: WebViewerInstance | null; -setInstance: Dispatch<SetStateAction<WebViewerInstance | null>>; -t?: (key: string) => string; + contractId?: number; + filePath?: string; + additionalFiles?: FileInfo[]; + templateName?: string; + isOpen?: boolean; + onClose?: () => void; + onSign?: (documentData: ArrayBuffer, surveyData?: any) => Promise<void>; + instance: WebViewerInstance | null; + setInstance: Dispatch<SetStateAction<WebViewerInstance | null>>; + onSurveyComplete?: () => void; // 🔥 새로 추가 + onSignatureComplete?: () => void; // 🔥 새로 추가 + t?: (key: string) => string; +} + +// 폼 데이터 타입 정의 +interface SurveyFormData { + [key: string]: { + answerValue?: string; + detailText?: string; + otherText?: string; + files?: File[]; + }; } -// ✅ 자동 서명 필드 생성을 위한 타입 정의 +// 자동 서명 필드 생성을 위한 타입 정의 interface SignaturePattern { regex: RegExp; name: string; @@ -62,22 +76,7 @@ interface SignaturePattern { height?: number; } -interface DetectedSignatureLocation { - pageIndex: number; - text: string; - rect: { - x1: number; - y1: number; - x2: number; - y2: number; - }; - pattern: SignaturePattern; - confidence: number; -} - -// ✅ 개선된 자동 서명 필드 감지 클래스 - -// ✅ 초간단 안전한 서명 필드 감지 클래스 (새로고침 제거) +// 초간단 안전한 서명 필드 감지 클래스 class AutoSignatureFieldDetector { private instance: WebViewerInstance; private signaturePatterns: SignaturePattern[]; @@ -89,7 +88,6 @@ class AutoSignatureFieldDetector { private initializePatterns(): SignaturePattern[] { return [ - // 한국어 패턴들 { regex: /서명\s*[::]\s*[_\-\s]{3,}/gi, name: "한국어_서명_콜론", @@ -108,7 +106,6 @@ class AutoSignatureFieldDetector { width: 150, height: 40 }, - // 영어 패턴들 { regex: /signature\s*[::]\s*[_\-\s]{3,}/gi, name: "영어_signature_콜론", @@ -132,37 +129,29 @@ class AutoSignatureFieldDetector { async detectAndCreateSignatureFields(): Promise<string[]> { console.log("🔍 안전한 서명 필드 감지 시작..."); - + try { - // ✅ 1단계: 기본 유효성 검사만 if (!this.instance?.Core?.documentViewer) { throw new Error("WebViewer 인스턴스가 유효하지 않습니다."); } const { Core } = this.instance; const { documentViewer } = Core; - - // ✅ 2단계: 문서 존재 확인만 (getPDFDoc 호출 안함) + const document = documentViewer.getDocument(); if (!document) { throw new Error("PDF 문서가 로드되지 않았습니다."); } - console.log("📄 문서 확인 완료, 기존 필드 검사..."); - - - // ✅ 4단계: 단순 기본 서명 필드 생성 (텍스트 분석 스킵) - console.log("📝 기본 서명 필드 생성..."); + console.log("📄 문서 확인 완료, 기본 서명 필드 생성..."); const defaultField = await this.createSimpleSignatureField(); - - // ✅ 5단계: 새로고침 없이 완료 - console.log("✅ 서명 필드 생성 완료 (새로고침 스킵)"); + + console.log("✅ 서명 필드 생성 완료"); return [defaultField]; } catch (error) { - console.error("📛 안전한 서명 필드 생성 실패:", error); - - // 에러 타입별 메시지 + console.error("📛 서명 필드 생성 실패:", error); + let errorMessage = "서명 필드 생성에 실패했습니다."; if (error instanceof Error) { if (error.message.includes("인스턴스")) { @@ -171,35 +160,24 @@ class AutoSignatureFieldDetector { errorMessage = "문서를 불러오는 중입니다."; } } - + throw new Error(errorMessage); } } - - - // ✅ 초간단 서명 필드 생성 (복잡한 텍스트 분석 없이) private async createSimpleSignatureField(): Promise<string> { try { - const { Core, UI } = this.instance; + const { Core } = this.instance; const { documentViewer, annotationManager, Annotations } = Core; - - // 페이지 정보 안전하게 가져오기 + const pageCount = documentViewer.getPageCount(); - const lastPageIndex = Math.max(0, pageCount - 1); - - // 페이지 크기 안전하게 가져오기 const pageWidth = documentViewer.getPageWidth(pageCount) || 612; const pageHeight = documentViewer.getPageHeight(pageCount) || 792; - + console.log(`📏 페이지 정보: ${pageCount}페이지, 크기 ${pageWidth}x${pageHeight}`); - - // ✅ 간단한 서명 어노테이션 생성 (PDFDoc 접근 없이) - const fieldName = `simple_signature_${Date.now()}`; + const fieldName = `simple_signature_${Date.now()}`; const flags = new Annotations.WidgetFlags(); - // flags.set(Annotations.WidgetFlags.REQUIRED, true); - // flags.set(Annotations.WidgetFlags.READ_ONLY, true); const formField = new Core.Annotations.Forms.Field( `SignatureFormField`, @@ -208,89 +186,36 @@ class AutoSignatureFieldDetector { flags, } ); - - // 서명 위젯 어노테이션 생성 - const signatureWidget = new Annotations.SignatureWidgetAnnotation(formField,{ - // appearance: Annotations.SignatureWidgetAnnotation.DefaultAppearance.MATERIAL_OUTLINE, + + const signatureWidget = new Annotations.SignatureWidgetAnnotation(formField, { Width: 150, Height: 50 }); - - // 위치 설정 (마지막 페이지 하단) + signatureWidget.setPageNumber(pageCount); signatureWidget.setX(pageWidth * 0.7); signatureWidget.setY(pageHeight * 0.85); signatureWidget.setWidth(150); signatureWidget.setHeight(50); - - // 필드명 설정 - // signatureWidget.setFieldName(fieldName); - // signatureWidget.setCustomData('fieldName', fieldName); - - // // 스타일 설정 - // signatureWidget.StrokeColor = new Annotations.Color(0, 100, 200); // 파란색 - // signatureWidget.StrokeThickness = 2; - - // 어노테이션 추가 + annotationManager.addAnnotation(signatureWidget); annotationManager.redrawAnnotation(signatureWidget); - - console.log(`✅ 간단 서명 필드 생성: ${fieldName}`); + + console.log(`✅ 서명 필드 생성: ${fieldName}`); return fieldName; - + } catch (error) { - console.error("📛 간단 서명 필드 생성 실패:", error); - - // ✅ 최후의 수단: 텍스트 어노테이션으로 안내 - // return await this.createTextGuidance(); + console.error("📛 서명 필드 생성 실패:", error); + return "manual_signature_required"; } } - - // ✅ 최후의 수단: 텍스트 안내 생성 - // private async createTextGuidance(): Promise<string> { - // try { - // const { Core } = this.instance; - // const { documentViewer, annotationManager, Annotations } = Core; - - // const pageCount = documentViewer.getPageCount(); - // const pageWidth = documentViewer.getPageWidth(pageCount) || 612; - // const pageHeight = documentViewer.getPageHeight(pageCount) || 792; - - // // 텍스트 어노테이션으로 서명 안내 - // const textAnnot = new Annotations.FreeTextAnnotation(); - // textAnnot.setPageNumber(pageCount); - // textAnnot.setX(pageWidth * 0.25); - // textAnnot.setY(pageHeight * 0.1); - // textAnnot.setWidth(pageWidth * 0.5); - // textAnnot.setHeight(60); - // textAnnot.setContents("👆 여기를 클릭하여 서명해주세요"); - // textAnnot.FontSize = '14pt'; - // textAnnot.TextColor = new Annotations.Color(255, 0, 0); // 빨간색 - // textAnnot.StrokeColor = new Annotations.Color(255, 200, 200); - // textAnnot.FillColor = new Annotations.Color(255, 240, 240); - - // const fieldName = `text_guidance_${Date.now()}`; - // textAnnot.setCustomData('fieldName', fieldName); - - // annotationManager.addAnnotation(textAnnot); - // annotationManager.redrawAnnotation(textAnnot); - - // console.log(`✅ 텍스트 안내 생성: ${fieldName}`); - // return fieldName; - - // } catch (error) { - // console.error("📛 텍스트 안내 생성도 실패:", error); - // return "manual_signature_required"; - // } - // } } function useAutoSignatureFields(instance: WebViewerInstance | null) { const [signatureFields, setSignatureFields] = useState<string[]>([]); const [isProcessing, setIsProcessing] = useState(false); const [error, setError] = useState<string | null>(null); - - // 중복 실행 방지 + const processingRef = useRef(false); const timeoutRef = useRef<NodeJS.Timeout | null>(null); @@ -298,65 +223,46 @@ function useAutoSignatureFields(instance: WebViewerInstance | null) { if (!instance) return; const { documentViewer } = instance.Core; - + const handleDocumentLoaded = () => { - // ✅ 중복 실행 방지 if (processingRef.current) { console.log("📛 이미 처리 중이므로 스킵"); return; } - // ✅ 기존 타이머 정리 if (timeoutRef.current) { clearTimeout(timeoutRef.current); timeoutRef.current = null; } - // ✅ 짧은 지연 후 실행 (3초) timeoutRef.current = setTimeout(async () => { if (processingRef.current) return; - + processingRef.current = true; setIsProcessing(true); setError(null); - + try { - console.log("📄 문서 로드 완료, 안전한 서명 필드 처리 시작..."); - - // ✅ 최종 유효성 검사 + console.log("📄 문서 로드 완료, 서명 필드 처리 시작..."); + if (!instance?.Core?.documentViewer?.getDocument()) { throw new Error("문서가 준비되지 않았습니다."); } const detector = new AutoSignatureFieldDetector(instance); const fields = await detector.detectAndCreateSignatureFields(); - + setSignatureFields(fields); - - // ✅ 결과에 따른 토스트 메시지 + if (fields.length > 0) { const hasSimpleField = fields.some(field => field.startsWith('simple_signature_')); - const hasTextGuidance = fields.some(field => field.startsWith('text_guidance_')); - const hasManualRequired = fields.includes('manual_signature_required'); - + if (hasSimpleField) { toast.success("📝 서명 필드가 생성되었습니다.", { description: "마지막 페이지 하단의 파란색 영역에서 서명해주세요.", icon: <FileSignature className="h-4 w-4 text-blue-500" />, duration: 5000 }); - } else if (hasTextGuidance) { - toast.success("📍 서명 안내가 표시되었습니다.", { - description: "빨간색 텍스트 영역을 클릭하여 서명해주세요.", - icon: <Target className="h-4 w-4 text-red-500" />, - duration: 6000 - }); - } else if (hasManualRequired) { - toast.info("수동 서명이 필요합니다.", { - description: "문서에서 서명할 위치를 직접 클릭해주세요.", - icon: <AlertTriangle className="h-4 w-4 text-amber-500" />, - duration: 5000 - }); } else { toast.success(`📋 ${fields.length}개의 서명 필드를 확인했습니다.`, { description: "기존 서명 필드가 발견되었습니다.", @@ -364,56 +270,40 @@ function useAutoSignatureFields(instance: WebViewerInstance | null) { duration: 4000 }); } - } else { - toast.info("서명 필드 준비 중", { - description: "문서에서 서명할 위치를 클릭해주세요.", - icon: <FileSignature className="h-4 w-4 text-blue-500" />, - duration: 4000 - }); } - + } catch (error) { - console.error("📛 안전한 서명 필드 처리 실패:", error); - + console.error("📛 서명 필드 처리 실패:", error); + const errorMessage = error instanceof Error ? error.message : "서명 필드 처리에 실패했습니다."; setError(errorMessage); - - // ✅ 부드러운 에러 처리 - if (errorMessage.includes("준비")) { - toast.info("문서 로딩 중", { - description: "잠시 후 다시 시도하거나 수동으로 서명해주세요.", - icon: <Loader2 className="h-4 w-4 text-blue-500" /> - }); - } else { - toast.info("수동 서명 모드", { - description: "문서에서 서명할 위치를 직접 클릭해주세요.", - icon: <FileSignature className="h-4 w-4 text-blue-500" /> - }); - } + + toast.info("수동 서명 모드", { + description: "문서에서 서명할 위치를 직접 클릭해주세요.", + icon: <FileSignature className="h-4 w-4 text-blue-500" /> + }); } finally { setIsProcessing(false); processingRef.current = false; } - }, 3000); // 3초 지연 + }, 3000); }; - // ✅ 이벤트 리스너 등록 documentViewer.removeEventListener('documentLoaded', handleDocumentLoaded); documentViewer.addEventListener('documentLoaded', handleDocumentLoaded); return () => { documentViewer.removeEventListener('documentLoaded', handleDocumentLoaded); - + if (timeoutRef.current) { clearTimeout(timeoutRef.current); timeoutRef.current = null; } - + processingRef.current = false; }; }, [instance]); - // ✅ 컴포넌트 언마운트 시 정리 useEffect(() => { return () => { if (timeoutRef.current) { @@ -431,1083 +321,1574 @@ function useAutoSignatureFields(instance: WebViewerInstance | null) { }; } -export function BasicContractSignViewer({ -contractId, -filePath, -additionalFiles = [], -templateName = "", -isOpen = false, -onClose, -onSign, -instance, -setInstance, -t = (key: string) => key, -}: BasicContractSignViewerProps) { +// 🔥 서명 감지를 위한 커스텀 훅 수정 +function useSignatureDetection(instance: WebViewerInstance | null, onSignatureComplete?: () => void) { + const [hasValidSignature, setHasValidSignature] = useState(false); + const checkIntervalRef = useRef<NodeJS.Timeout | null>(null); + const lastSignatureStateRef = useRef(false); + const onSignatureCompleteRef = useRef(onSignatureComplete); - console.log("🔍 BasicContractSignViewer props:", { - contractId, - filePath, - additionalFiles, - templateName, - isNDATemplate: templateName.includes('비밀유지') || templateName.includes('NDA') - }); - -const [fileLoading, setFileLoading] = useState<boolean>(true); -const [activeTab, setActiveTab] = useState<string>("main"); -const [surveyData, setSurveyData] = useState<any>({}); -const [surveyAnswers, setSurveyAnswers] = useState<Record<number, any>>({}); -const [surveyTemplate, setSurveyTemplate] = useState<any>(null); -const [surveyLoading, setSurveyLoading] = useState<boolean>(false); -const [uploadedFiles, setUploadedFiles] = useState<Record<number, File[]>>({}); -const [isInitialLoaded, setIsInitialLoaded] = useState<boolean>(false); - -const viewer = useRef<HTMLDivElement>(null); -const initialized = useRef(false); -const isCancelled = useRef(false); -const currentDocumentPath = useRef<string>(""); -const [showDialog, setShowDialog] = useState(isOpen); -const webViewerInstance = useRef<WebViewerInstance | null>(null); - -// ✅ 자동 서명 필드 생성 훅 사용 -const { signatureFields, isProcessing: isAutoSignProcessing, hasSignatureFields, error: autoSignError } = useAutoSignatureFields(webViewerInstance.current || instance); - -// 템플릿 타입 판단 -const isComplianceTemplate = templateName.includes('준법'); -const isNDATemplate = templateName.includes('비밀유지') || templateName.includes('NDA'); - -// 파일 목록 생성 -const allFiles: FileInfo[] = React.useMemo(() => { - const files: FileInfo[] = []; - - if (filePath) { - files.push({ - path: filePath, - name: templateName || "기본 계약서", - type: "main", - }); - } - - const normalizedAttachments: FileInfo[] = (additionalFiles || []) - .map((f: any, idx: number) => ({ - path: f.path ?? f.filePath ?? "", - name: `첨부파일 ${idx + 1}`, - type: "attachment" as const, - })) - .filter(f => !!f.path); - - files.push(...normalizedAttachments); - - if (isComplianceTemplate) { - files.push({ - path: "", - name: "준법 설문조사", - type: "survey", - }); - } + // 콜백 레퍼런스 업데이트 + useEffect(() => { + onSignatureCompleteRef.current = onSignatureComplete; + }, [onSignatureComplete]); - console.log("📂 생성된 allFiles:", files, { isNDATemplate, isComplianceTemplate }); - return files; -}, [filePath, additionalFiles, templateName, isComplianceTemplate, isNDATemplate]); + const checkSignatureFields = useCallback(async () => { + if (!instance?.Core?.annotationManager) { + console.log('🔍 서명 체크: annotationManager 없음'); + return false; + } -// WebViewer 정리 함수 -const cleanupWebViewer = () => { - console.log("🧹 WebViewer 정리 시작"); - - if (webViewerInstance.current) { try { - const { documentViewer } = webViewerInstance.current.Core; - if (documentViewer && documentViewer.getDocument()) { - documentViewer.closeDocument(); + const { annotationManager, documentViewer } = instance.Core; + + // 문서가 로드되지 않았으면 false 반환 + if (!documentViewer.getDocument()) { + console.log('🔍 서명 체크: 문서 미로드'); + return false; } + + let hasSignature = false; + + // 1. Form Fields 확인 (더 정확한 방법) + const fieldManager = annotationManager.getFieldManager(); + const fields = fieldManager.getFields(); - if (webViewerInstance.current.UI && typeof webViewerInstance.current.UI.dispose === 'function') { - webViewerInstance.current.UI.dispose(); + console.log('🔍 폼 필드 확인:', fields.map(field => ({ + name: field.name, + type: field.type, + value: field.value, + hasValue: !!field.value + }))); + + // 서명 필드 확인 + for (const field of fields) { + // PDFTron에서 서명 필드는 보통 'Sig' 타입이지만, 값이 있는지 정확히 확인 + if (field.type === 'Sig' || field.name?.toLowerCase().includes('signature')) { + if (field.value && ( + typeof field.value === 'string' && field.value.length > 0 || + typeof field.value === 'object' && field.value !== null + )) { + hasSignature = true; + console.log('🔍 서명 필드에서 서명 발견:', field.name, field.value); + break; + } + } } + + // 2. Signature Widget Annotations 확인 + if (!hasSignature) { + const annotations = annotationManager.getAnnotationsList(); + console.log('🔍 주석 확인:', annotations.length, '개'); + + for (const annotation of annotations) { + // SignatureWidgetAnnotation 타입 확인 + if (annotation.elementName === 'signatureWidget' || + annotation.constructor.name === 'SignatureWidgetAnnotation' || + annotation.Subject === 'Signature') { + + // 서명 데이터가 있는지 확인 + const hasSignatureData = annotation.getImageData && annotation.getImageData() || + annotation.getPath && annotation.getPath() || + annotation.getCustomData && annotation.getCustomData('signature-data'); + + if (hasSignatureData) { + hasSignature = true; + console.log('🔍 서명 위젯에서 서명 발견:', annotation); + break; + } + } + } + } + + // 3. Ink/FreeHand Annotations 확인 (직접 그린 서명) + if (!hasSignature) { + const annotations = annotationManager.getAnnotationsList(); + + for (const annotation of annotations) { + if (annotation.elementName === 'freeHand' || + annotation.elementName === 'ink' || + annotation.constructor.name === 'FreeHandAnnotation') { + + // 경로 데이터가 있으면 서명으로 간주 + const hasPath = annotation.getPath && annotation.getPath().length > 0; + if (hasPath) { + hasSignature = true; + console.log('🔍 자유 그리기에서 서명 발견:', annotation); + break; + } + } + } + } + + console.log('🔍 최종 서명 감지 결과:', { + hasSignature, + fieldsCount: fields.length, + annotationsCount: annotationManager.getAnnotationsList().length + }); + + return hasSignature; } catch (error) { - console.warn("WebViewer 정리 중 에러 (무시됨):", error); + console.error('📛 서명 확인 중 에러:', error); + return false; } + }, [instance]); + + // 실시간 서명 감지 (무한 렌더링 방지) + useEffect(() => { + if (!instance?.Core) return; + + const startMonitoring = () => { + // 기존 인터벌 정리 + if (checkIntervalRef.current) { + clearInterval(checkIntervalRef.current); + checkIntervalRef.current = null; + } + + console.log('🔍 서명 모니터링 시작'); + + // 2초마다 서명 상태 확인 (1초보다 간격을 늘려 성능 개선) + checkIntervalRef.current = setInterval(async () => { + try { + const hasSignature = await checkSignatureFields(); + + // 상태가 실제로 변경되었을 때만 업데이트 + if (hasSignature !== lastSignatureStateRef.current) { + console.log('🔍 서명 상태 변경:', lastSignatureStateRef.current, '->', hasSignature); + + lastSignatureStateRef.current = hasSignature; + setHasValidSignature(hasSignature); + + // 서명이 완료되었을 때 콜백 실행 + if (hasSignature && onSignatureCompleteRef.current) { + console.log('✍️ 서명 완료 콜백 실행!'); + onSignatureCompleteRef.current(); + } + } + } catch (error) { + console.error('📛 서명 모니터링 에러:', error); + } + }, 2000); + }; + + // 문서 로드 후 모니터링 시작 + const { documentViewer } = instance.Core; - webViewerInstance.current = null; - } - - if (instance && setInstance) { - setInstance(null); - } - - setTimeout(() => cleanupHtmlStyle(), 100); -}; + const handleDocumentLoaded = () => { + console.log('📄 문서 로드 완료, 서명 모니터링 준비'); + // 문서 로드 후 3초 뒤에 모니터링 시작 (안정성 확보) + setTimeout(startMonitoring, 3000); + }; -// 다이얼로그 및 파일 상태 변경 시 리셋 -useEffect(() => { - setShowDialog(isOpen); - - if (isOpen && isComplianceTemplate && !surveyTemplate) { - loadSurveyTemplate(); - } + if (documentViewer?.getDocument()) { + // 이미 문서가 로드되어 있다면 바로 시작 + setTimeout(startMonitoring, 1000); + } else { + // 문서 로드 대기 + documentViewer?.addEventListener('documentLoaded', handleDocumentLoaded); + } + + // 클리너 함수 + return () => { + console.log('🧹 서명 모니터링 정리'); + if (checkIntervalRef.current) { + clearInterval(checkIntervalRef.current); + checkIntervalRef.current = null; + } + documentViewer?.removeEventListener('documentLoaded', handleDocumentLoaded); + }; + }, [instance]); // onSignatureComplete 제거하여 무한 렌더링 방지 + + // 수동 서명 확인 함수 + const manualCheckSignature = useCallback(async () => { + console.log('🔍 수동 서명 확인 요청'); + const hasSignature = await checkSignatureFields(); + setHasValidSignature(hasSignature); + lastSignatureStateRef.current = hasSignature; + return hasSignature; + }, [checkSignatureFields]); + + return { + hasValidSignature, + checkSignature: manualCheckSignature + }; +} + +export function BasicContractSignViewer({ + contractId, + filePath, + additionalFiles = [], + templateName = "", + isOpen = false, + onClose, + onSign, + instance, + setInstance, + onSurveyComplete, // 🔥 추가 + onSignatureComplete, // 🔥 추가 + t = (key: string) => key, +}: BasicContractSignViewerProps) { + + const [fileLoading, setFileLoading] = useState<boolean>(true); + const [activeTab, setActiveTab] = useState<string>("main"); + const [surveyData, setSurveyData] = useState<any>({}); + const [isInitialLoaded, setIsInitialLoaded] = useState<boolean>(false); + const [surveyTemplate, setSurveyTemplate] = useState<SurveyTemplateWithQuestions | null>(null); + const [surveyLoading, setSurveyLoading] = useState<boolean>(false); + const [isSubmitting, setIsSubmitting] = useState(false); // 제출 상태 추가 + + const conditionalHandler = useConditionalSurvey(surveyTemplate); + + const viewer = useRef<HTMLDivElement>(null); + const initialized = useRef(false); + const isCancelled = useRef(false); + const currentDocumentPath = useRef<string>(""); + const [showDialog, setShowDialog] = useState(isOpen); + const webViewerInstance = useRef<WebViewerInstance | null>(null); + + const { signatureFields, isProcessing: isAutoSignProcessing, hasSignatureFields, error: autoSignError } = useAutoSignatureFields(webViewerInstance.current || instance); - if (isOpen) { + // 🔥 서명 감지 훅 사용 + const { hasValidSignature } = useSignatureDetection(webViewerInstance.current || instance, onSignatureComplete); + + const isComplianceTemplate = templateName.includes('준법'); + const isNDATemplate = templateName.includes('비밀유지') || templateName.includes('NDA'); + + const allFiles: FileInfo[] = React.useMemo(() => { + const files: FileInfo[] = []; + + if (filePath) { + files.push({ + path: filePath, + name: templateName || "기본 계약서", + type: "main", + }); + } + + const normalizedAttachments: FileInfo[] = (additionalFiles || []) + .map((f: any, idx: number) => ({ + path: f.path ?? f.filePath ?? "", + name: `첨부파일 ${idx + 1}`, + type: "attachment" as const, + })) + .filter(f => !!f.path); + + files.push(...normalizedAttachments); + + if (isComplianceTemplate) { + files.push({ + path: "", + name: "준법 설문조사", + type: "survey", + }); + } + + return files; + }, [filePath, additionalFiles, templateName, isComplianceTemplate]); + + const cleanupHtmlStyle = () => { + const elements = document.querySelectorAll('.Document_container'); + elements.forEach((elem) => { + elem.remove(); + }); + }; + + const cleanupWebViewer = () => { + console.log("🧹 WebViewer 정리 시작"); + + if (webViewerInstance.current) { + try { + const { documentViewer } = webViewerInstance.current.Core; + if (documentViewer && documentViewer.getDocument()) { + documentViewer.closeDocument(); + } + + if (webViewerInstance.current.UI && typeof webViewerInstance.current.UI.dispose === 'function') { + webViewerInstance.current.UI.dispose(); + } + } catch (error) { + console.warn("WebViewer 정리 중 에러 (무시됨):", error); + } + + webViewerInstance.current = null; + } + + if (instance && setInstance) { + setInstance(null); + } + + setTimeout(() => cleanupHtmlStyle(), 100); + }; + + useEffect(() => { + setShowDialog(isOpen); + + if (isOpen && isComplianceTemplate && !surveyTemplate) { + loadSurveyTemplate(); + } + + if (isOpen) { + setIsInitialLoaded(false); + currentDocumentPath.current = ""; + } + }, [isOpen, isComplianceTemplate]); + + useEffect(() => { + if (!filePath) return; + setIsInitialLoaded(false); currentDocumentPath.current = ""; - console.log("🔄 새로운 계약서 열림, 상태 리셋"); - } -}, [isOpen, isComplianceTemplate]); + setActiveTab("main"); -// filePath 변경 시 상태 리셋 및 즉시 문서 로드 -useEffect(() => { - if (!filePath) return; - - console.log("🔄 filePath 변경으로 상태 리셋 및 문서 로드:", filePath); - - setIsInitialLoaded(false); - currentDocumentPath.current = ""; - setActiveTab("main"); - - const currentInstance = webViewerInstance.current || instance; - - if (currentInstance) { - const normalizedPath = filePath.startsWith('/') ? filePath.substring(1) : filePath; - const encodedPath = normalizedPath.split('/').map(part => encodeURIComponent(part)).join('/'); - const apiFilePath = `/api/files/${encodedPath}`; - - console.log("📄 filePath 변경으로 즉시 문서 로드:", apiFilePath); - - loadDocument(currentInstance, apiFilePath, true).then(() => { - setIsInitialLoaded(true); - console.log("✅ filePath 변경 문서 로드 완료"); - }).catch((error) => { - console.error("📛 filePath 변경 문서 로드 실패:", error); - }); - } -}, [filePath, instance]); + const currentInstance = webViewerInstance.current || instance; -const loadSurveyTemplate = async () => { - setSurveyLoading(true); - - const mockTemplate = { - id: 1, - name: '기본 준법 설문조사', - description: '모든 계약업체 대상 기본 준법 설문조사', - questions: [ - { - id: 4, - questionNumber: '4', - questionText: '귀사의 법률적 조직형태는?', - questionType: 'DROPDOWN', - isRequired: true, - hasDetailText: false, - hasFileUpload: false, - options: [ - { id: 1, optionValue: 'COMPANY_CORP', optionText: '주식회사/유한회사' }, - { id: 2, optionValue: 'INDIVIDUAL', optionText: '개인회사' }, - { id: 3, optionValue: 'PARTNERSHIP', optionText: '조합' }, - { id: 4, optionValue: 'JOINT_VENTURE', optionText: '조인트벤처' }, - { id: 5, optionValue: 'OTHER', optionText: '기타', allowsOtherInput: true }, - ] - }, - { - id: 6, - questionNumber: '6', - questionText: '부패방지와 관련한 귀사의 준법정책이 있습니까? 있다면 첨부파일로 제공하여 주시기 바랍니다.', - questionType: 'RADIO', - isRequired: true, - hasDetailText: false, - hasFileUpload: true, - options: [ - { id: 6, optionValue: 'YES', optionText: '네' }, - { id: 7, optionValue: 'NO', optionText: '아니오' }, - ] - }, - { - id: 11, - questionNumber: '11', - questionText: '귀사의 사주, 임원 중에서 전(최근 3년내)·현직 공직자인 사람이 있습니까? 만약 있다면 상세하게 기술해 주십시오.', - questionType: 'RADIO', - isRequired: true, - hasDetailText: true, - hasFileUpload: false, - options: [ - { id: 11, optionValue: 'YES', optionText: '네' }, - { id: 12, optionValue: 'NO', optionText: '아니오' }, - ] - }, - ] + if (currentInstance) { + const normalizedPath = filePath.startsWith('/') ? filePath.substring(1) : filePath; + const encodedPath = normalizedPath.split('/').map(part => encodeURIComponent(part)).join('/'); + const apiFilePath = `/api/files/${encodedPath}`; + + loadDocument(currentInstance, apiFilePath, true).then(() => { + setIsInitialLoaded(true); + }).catch((error) => { + console.error("📛 문서 로드 실패:", error); + }); + } + }, [filePath, instance]); + + const loadSurveyTemplate = async () => { + setSurveyLoading(true); + + try { + const template = await getActiveSurveyTemplate(); + setSurveyTemplate(template); + } catch (error) { + console.error('📛 설문조사 템플릿 로드 실패:', error); + setSurveyTemplate(null); + } finally { + setSurveyLoading(false); + } }; - - setSurveyTemplate(mockTemplate); - setSurveyLoading(false); -}; - -// WebViewer 초기화 개선 -useEffect(() => { - if (!initialized.current && viewer.current) { - initialized.current = true; - isCancelled.current = false; - - const initializeWebViewer = () => { - if (!viewer.current || isCancelled.current) { - console.log("📛 WebViewer 초기화 취소됨 (DOM 없음)"); - return; - } - const viewerElement = viewer.current; - - if (!viewerElement.isConnected) { - console.log("📛 WebViewer DOM이 연결되지 않음, 재시도..."); - setTimeout(initializeWebViewer, 100); - return; - } + useEffect(() => { + if (!initialized.current && viewer.current) { + initialized.current = true; + isCancelled.current = false; - cleanupWebViewer(); + const initializeWebViewer = () => { + if (!viewer.current || isCancelled.current) { + return; + } - console.log("📄 WebViewer 초기화 시작..."); - - import("@pdftron/webviewer").then(({ default: WebViewer }) => { - if (isCancelled.current || !viewer.current) { - console.log("📛 WebViewer 초기화 취소됨 (import 후)"); + const viewerElement = viewer.current; + + if (!viewerElement.isConnected) { + setTimeout(initializeWebViewer, 100); return; } - WebViewer( - { - path: "/pdftronWeb", - licenseKey: process.env.NEXT_PUBLIC_PDFTRON_WEBVIEW_KEY, - fullAPI: true , - disabledElements: [ - - ] - }, - viewerElement - ).then((newInstance) => { - if (isCancelled.current) { - console.log("📛 WebViewer 인스턴스 생성 후 취소됨"); + cleanupWebViewer(); + + import("@pdftron/webviewer").then(({ default: WebViewer }) => { + if (isCancelled.current || !viewer.current) { return; } - console.log("📄 WebViewer 초기화 완료"); - - webViewerInstance.current = newInstance; - setInstance(newInstance); - setFileLoading(false); - - const { documentViewer } = newInstance.Core; - const FitMode = newInstance.UI.FitMode; - - // 문서 로드 완료 시 처리 - const handleDocumentLoaded = () => { + WebViewer( + { + path: "/pdftronWeb", + licenseKey: process.env.NEXT_PUBLIC_PDFTRON_WEBVIEW_KEY, + fullAPI: true, + }, + viewerElement + ).then((newInstance) => { + if (isCancelled.current) { + return; + } + + webViewerInstance.current = newInstance; + setInstance(newInstance); setFileLoading(false); - newInstance.UI.setFitMode(FitMode.FitWidth); - - requestAnimationFrame(() => { - try { - documentViewer.refreshAll(); - documentViewer.updateView(); - window.dispatchEvent(new Event("resize")); - setTimeout(() => window.dispatchEvent(new Event("resize")), 100); - } catch (e) { - console.warn("layout refresh skipped", e); + + const { documentViewer } = newInstance.Core; + const FitMode = newInstance.UI.FitMode; + + const handleDocumentLoaded = () => { + setFileLoading(false); + newInstance.UI.setFitMode(FitMode.FitWidth); + + requestAnimationFrame(() => { + try { + documentViewer.refreshAll(); + documentViewer.updateView(); + window.dispatchEvent(new Event("resize")); + setTimeout(() => window.dispatchEvent(new Event("resize")), 100); + } catch (e) { + console.warn("layout refresh skipped", e); + } + }); + }; + + documentViewer.addEventListener('documentLoaded', handleDocumentLoaded); + + newInstance.UI.setMinZoomLevel('25%'); + newInstance.UI.setMaxZoomLevel('400%'); + + newInstance.UI.disableElements([ + "toolbarGroup-Annotate", + "toolbarGroup-Shapes", + "toolbarGroup-Insert", + "toolbarGroup-Edit", + "toolbarGroup-FillAndSign", + "toolbarGroup-Forms", + "saveAsButton", + "downloadButton", + ]); + + documentViewer.addEventListener('documentLoadingError', (error) => { + console.error("📛 문서 로딩 에러:", error); + + let showToast = true; + let errorMessage = "문서를 불러오는데 실패했습니다."; + + if (error && typeof error === 'object') { + const errorStr = JSON.stringify(error).toLowerCase(); + + if (errorStr.includes('linearized') || errorStr.includes('getreference')) { + showToast = false; + } else if (errorStr.includes('network')) { + errorMessage = "네트워크 연결을 확인해주세요."; + } else if (errorStr.includes('permission')) { + errorMessage = "문서에 접근할 권한이 없습니다."; + } } - }); - }; - - documentViewer.addEventListener('documentLoaded', handleDocumentLoaded); - - documentViewer.addEventListener('layoutChanged', () => { - if (newInstance.UI.getFitMode && newInstance.UI.getFitMode() !== FitMode.Zoom) { - newInstance.UI.setFitMode(FitMode.Zoom); - } - }); - - newInstance.UI.setMinZoomLevel('25%'); - newInstance.UI.setMaxZoomLevel('400%'); - - newInstance.UI.disableElements([ - "toolbarGroup-Annotate", - "toolbarGroup-Shapes", - "toolbarGroup-Insert", - "toolbarGroup-Edit", - "toolbarGroup-FillAndSign", - "toolbarGroup-Forms", - "saveAsButton", - "downloadButton", - - ]) - - documentViewer.addEventListener('documentLoadingError', (error) => { - console.error("📛 WebViewer 문서 로딩 에러:", error); - - let showToast = true; - let errorMessage = "문서를 불러오는데 실패했습니다."; - - if (error && typeof error === 'object') { - const errorStr = JSON.stringify(error).toLowerCase(); - - if (errorStr.includes('linearized') || errorStr.includes('getreference')) { - console.warn("⚠️ PDF 구조 경고 (문서 로드는 진행됨)"); - showToast = false; - } else if (errorStr.includes('network')) { - errorMessage = "네트워크 연결을 확인해주세요."; - } else if (errorStr.includes('permission')) { - errorMessage = "문서에 접근할 권한이 없습니다."; + if (showToast) { + setFileLoading(false); + toast.error(errorMessage); } - } - - if (showToast) { - setFileLoading(false); - toast.error(errorMessage); - } - }); + }); + }).catch((error) => { + console.error("📛 WebViewer 초기화 실패:", error); + setFileLoading(false); + toast.error("뷰어 초기화에 실패했습니다."); + }); }).catch((error) => { - console.error("📛 WebViewer 초기화 실패:", error); + console.error("📛 WebViewer 모듈 로드 실패:", error); setFileLoading(false); - toast.error("뷰어 초기화에 실패했습니다."); + toast.error("뷰어 모듈을 불러오는데 실패했습니다."); }); - }).catch((error) => { - console.error("📛 WebViewer 모듈 로드 실패:", error); - setFileLoading(false); - toast.error("뷰어 모듈을 불러오는데 실패했습니다."); + }; + + requestAnimationFrame(() => { + setTimeout(initializeWebViewer, 50); }); - }; + } - requestAnimationFrame(() => { - setTimeout(initializeWebViewer, 50); - }); - } + return () => { + isCancelled.current = true; + cleanupWebViewer(); + }; + }, [setInstance]); - return () => { - isCancelled.current = true; - cleanupWebViewer(); + const getExtFromPath = (p: string) => { + const m = p.toLowerCase().match(/\.([a-z0-9]+)(?:\?.*)?$/); + return m ? m[1] : undefined; }; -}, [setInstance]); - -// 확장자 추출 유틸 -const getExtFromPath = (p: string) => { - const m = p.toLowerCase().match(/\.([a-z0-9]+)(?:\?.*)?$/); - return m ? m[1] : undefined; -}; - -// 문서 로드 함수 개선 -const loadDocument = async ( - instance: WebViewerInstance, - documentPath: string, - forceReload = false -) => { - if (!forceReload && currentDocumentPath.current === documentPath) { - console.log("📄 동일한 문서이므로 스킵:", documentPath); - return; - } - - setFileLoading(true); - try { - console.log("📄 문서 로드 시작(UI):", documentPath, forceReload ? "(강제 리로드)" : ""); - if (!instance || !instance.UI || !instance.Core) { - throw new Error("WebViewer 인스턴스가 유효하지 않습니다."); + const loadDocument = async ( + instance: WebViewerInstance, + documentPath: string, + forceReload = false + ) => { + if (!forceReload && currentDocumentPath.current === documentPath) { + return; } - const ext = getExtFromPath(documentPath); - await instance.UI.loadDocument(documentPath, { - ...(ext ? { extension: ext } : {}), - filename: documentPath.split("/").pop(), - }); + setFileLoading(true); + try { + if (!instance || !instance.UI || !instance.Core) { + throw new Error("WebViewer 인스턴스가 유효하지 않습니다."); + } - currentDocumentPath.current = documentPath; - console.log("📄 문서 로드 완료(UI):", documentPath); + const ext = getExtFromPath(documentPath); + await instance.UI.loadDocument(documentPath, { + ...(ext ? { extension: ext } : {}), + filename: documentPath.split("/").pop(), + }); - const { documentViewer } = instance.Core; - requestAnimationFrame(() => { - try { - documentViewer.refreshAll(); - documentViewer.updateView(); - window.dispatchEvent(new Event("resize")); - setTimeout(() => window.dispatchEvent(new Event("resize")), 100); - } catch (e) { - console.warn("레이아웃 새로고침 스킵:", e); - } - }); - } catch (error) { - console.error("📛 문서 로딩 실패(UI):", error); - currentDocumentPath.current = ""; - - let msg = "문서를 불러오는데 실패했습니다."; - if (error instanceof Error) { - const s = error.message.toLowerCase(); - if (s.includes("network") || s.includes("fetch")) { - msg = "네트워크 연결을 확인해주세요."; - } else if (s.includes("permission") || s.includes("access")) { - msg = "문서에 접근할 권한이 없습니다."; - } else if (s.includes("corrupt") || s.includes("invalid")) { - msg = "파일이 손상되었거나 형식이 올바르지 않습니다."; - } else if (s.includes("linearized") || s.includes("getreference")) { - msg = ""; + currentDocumentPath.current = documentPath; + + const { documentViewer } = instance.Core; + requestAnimationFrame(() => { + try { + documentViewer.refreshAll(); + documentViewer.updateView(); + window.dispatchEvent(new Event("resize")); + setTimeout(() => window.dispatchEvent(new Event("resize")), 100); + } catch (e) { + console.warn("레이아웃 새로고침 스킵:", e); + } + }); + } catch (error) { + console.error("📛 문서 로딩 실패:", error); + currentDocumentPath.current = ""; + + let msg = "문서를 불러오는데 실패했습니다."; + if (error instanceof Error) { + const s = error.message.toLowerCase(); + if (s.includes("network") || s.includes("fetch")) { + msg = "네트워크 연결을 확인해주세요."; + } else if (s.includes("permission") || s.includes("access")) { + msg = "문서에 접근할 권한이 없습니다."; + } else if (s.includes("corrupt") || s.includes("invalid")) { + msg = "파일이 손상되었거나 형식이 올바르지 않습니다."; + } else if (s.includes("linearized") || s.includes("getreference")) { + msg = ""; + } } + if (msg) toast.error(msg); + } finally { + setFileLoading(false); } - if (msg) toast.error(msg); - } finally { - setFileLoading(false); - } -}; - -// 폼 데이터 수집 함수 -const collectFormData = async (instance: WebViewerInstance) => { - try { - const { documentViewer, annotationManager } = instance.Core; - const fieldManager = annotationManager.getFieldManager(); - const fields = fieldManager.getFields(); - - const formData: any = {}; - fields.forEach((field: any) => { - formData[field.name] = field.value; - }); - - console.log('📝 폼 데이터 수집:', formData); - return formData; - } catch (error) { - console.error('📛 폼 데이터 수집 실패:', error); - return {}; - } -}; + }; -// 탭 변경 핸들러 -const handleTabChange = async (newTab: string) => { - setActiveTab(newTab); - if (newTab === "survey") return; - - const currentInstance = webViewerInstance.current || instance; - if (!currentInstance || fileLoading) return; - - let targetFile: FileInfo | undefined; - if (newTab === "main") { - targetFile = allFiles.find(f => f.type === "main"); - } else if (newTab.startsWith("file-")) { - const fileIndex = parseInt(newTab.replace("file-", ""), 10); - targetFile = allFiles.filter(f => f.type !== 'survey')[fileIndex]; - } + const collectFormData = async (instance: WebViewerInstance) => { + try { + const { annotationManager } = instance.Core; + const fieldManager = annotationManager.getFieldManager(); + const fields = fieldManager.getFields(); - if (!targetFile?.path) { - console.warn("📛 대상 파일을 찾을 수 없음:", newTab, allFiles); - return; - } + const formData: any = {}; + fields.forEach((field: any) => { + formData[field.name] = field.value; + }); - const normalizedPath = targetFile.path.startsWith("/") - ? targetFile.path.substring(1) - : targetFile.path; - const encodedPath = normalizedPath.split("/").map(encodeURIComponent).join("/"); - const apiFilePath = `/api/files/${encodedPath}`; + return formData; + } catch (error) { + console.error('📛 폼 데이터 수집 실패:', error); + return {}; + } + }; - console.log("📄 탭 변경으로 문서 로드:", { newTab, targetFile, apiFilePath }); + const handleTabChange = async (newTab: string) => { + setActiveTab(newTab); + if (newTab === "survey") return; - try { - currentDocumentPath.current = ""; - await loadDocument(currentInstance, apiFilePath, true); - setIsInitialLoaded(true); + const currentInstance = webViewerInstance.current || instance; + if (!currentInstance || fileLoading) return; - const { documentViewer } = currentInstance.Core; - requestAnimationFrame(() => { - try { - documentViewer.refreshAll(); - documentViewer.updateView(); - window.dispatchEvent(new Event("resize")); - } catch (e) { - console.warn("탭 변경 후 레이아웃 새로고침 스킵:", e); - } - }); - } catch (e) { - console.error("📛 탭 변경 실패:", e); - } -}; - -// 초기 메인 문서 로드 개선 -useEffect(() => { - console.log("🔍 초기 로드 체크:", { - hasInstance: !!(webViewerInstance.current || instance), - hasFilePath: !!filePath, - activeTab, - isInitialLoaded, - allFilesLength: allFiles.length, - isNDATemplate - }); - - const currentInstance = webViewerInstance.current || instance; - - if (!currentInstance || !filePath || isInitialLoaded) { - return; - } - - const isMainTab = activeTab === 'main'; - const shouldLoadInitial = allFiles.length === 1 || isMainTab; - - if (!shouldLoadInitial || currentDocumentPath.current !== "") { - return; - } - - const normalizedPath = filePath.startsWith('/') ? filePath.substring(1) : filePath; - const encodedPath = normalizedPath.split('/').map(part => encodeURIComponent(part)).join('/'); - const apiFilePath = `/api/files/${encodedPath}`; - - console.log("📄 초기 마운트 문서 로드:", { apiFilePath, isNDATemplate, activeTab }); - - currentDocumentPath.current = ""; - - loadDocument(currentInstance, apiFilePath, true).then(() => { - setIsInitialLoaded(true); - console.log("✅ 초기 마운트 로드 완료"); - }).catch((error) => { - console.error("📛 초기 마운트 로드 실패:", error); - }); -}, [webViewerInstance.current, instance, filePath, activeTab, isInitialLoaded, allFiles.length, isNDATemplate]); - -// 설문조사 답변 업데이트 함수 -const updateSurveyAnswer = (questionId: number, field: string, value: any) => { - setSurveyAnswers(prev => ({ - ...prev, - [questionId]: { - ...prev[questionId], - questionId, - [field]: value + let targetFile: FileInfo | undefined; + if (newTab === "main") { + targetFile = allFiles.find(f => f.type === "main"); + } else if (newTab.startsWith("file-")) { + const fileIndex = parseInt(newTab.replace("file-", ""), 10); + targetFile = allFiles.filter(f => f.type !== 'survey')[fileIndex]; } - })); -}; -// 파일 업로드 핸들러 -const handleSurveyFileUpload = (questionId: number, files: FileList | null) => { - if (!files) return; - - const fileArray = Array.from(files); - setUploadedFiles(prev => ({ - ...prev, - [questionId]: fileArray - })); - - updateSurveyAnswer(questionId, 'files', fileArray); -}; + if (!targetFile?.path) { + console.warn("📛 대상 파일을 찾을 수 없음:", newTab, allFiles); + return; + } -// 질문 완료 여부 체크 -const isSurveyQuestionComplete = (question: any): boolean => { - const answer = surveyAnswers[question.id]; - - if (!question.isRequired) return true; - if (!answer?.answerValue) return false; - - if (question.hasDetailText && answer.answerValue === 'YES' && !answer.detailText) { - return false; - } - - if (question.hasFileUpload && answer.answerValue === 'YES' && (!answer.files || answer.files.length === 0)) { - return false; - } - - return true; -}; - -// 전체 설문조사 완료 여부 체크 -const isSurveyComplete = (): boolean => { - if (!surveyTemplate?.questions) return false; - return surveyTemplate.questions.every((question: any) => isSurveyQuestionComplete(question)); -}; - -// 설문조사 데이터 처리 -const handleSurveyComplete = async () => { - if (!isSurveyComplete()) { - toast.error('모든 필수 항목을 완료해주세요.', { - description: '미완성된 질문이 있습니다.', - icon: <AlertTriangle className="h-5 w-5 text-red-500" /> - }); - return; - } + const normalizedPath = targetFile.path.startsWith("/") + ? targetFile.path.substring(1) + : targetFile.path; + const encodedPath = normalizedPath.split("/").map(encodeURIComponent).join("/"); + const apiFilePath = `/api/files/${encodedPath}`; - try { - console.log('설문조사 답변:', surveyAnswers); - - setSurveyData({ - completed: true, - answers: Object.values(surveyAnswers), - timestamp: new Date().toISOString() - }); - - toast.success("설문조사가 완료되었습니다!", { - icon: <CheckCircle2 className="h-5 w-5 text-green-500" /> - }); - } catch (error) { - console.error('설문조사 저장 실패:', error); - toast.error('설문조사 저장에 실패했습니다.'); - } -}; + try { + currentDocumentPath.current = ""; + await loadDocument(currentInstance, apiFilePath, true); + setIsInitialLoaded(true); -// 서명 저장 핸들러 -const handleSave = async () => { - const currentInstance = webViewerInstance.current || instance; - if (!currentInstance) return; - - try { - const { documentViewer, annotationManager } = currentInstance.Core; - const doc = documentViewer.getDocument(); - - if (!doc) { - toast.error("문서가 로드되지 않았습니다."); + const { documentViewer } = currentInstance.Core; + requestAnimationFrame(() => { + try { + documentViewer.refreshAll(); + documentViewer.updateView(); + window.dispatchEvent(new Event("resize")); + } catch (e) { + console.warn("탭 변경 후 레이아웃 새로고침 스킵:", e); + } + }); + } catch (e) { + console.error("📛 탭 변경 실패:", e); + } + }; + + useEffect(() => { + const currentInstance = webViewerInstance.current || instance; + + if (!currentInstance || !filePath || isInitialLoaded) { return; } - - const formData = await collectFormData(currentInstance); - - const xfdfString = await annotationManager.exportAnnotations(); - const documentData = await doc.getFileData({ - xfdfString, - downloadType: "pdf", - }); - - if (isComplianceTemplate && !surveyData.completed) { - toast.error("준법 설문조사를 먼저 완료해주세요."); - setActiveTab('survey'); + + const isMainTab = activeTab === 'main'; + const shouldLoadInitial = allFiles.length === 1 || isMainTab; + + if (!shouldLoadInitial || currentDocumentPath.current !== "") { return; } - - if (onSign) { - await onSign(documentData, { formData, surveyData, signatureFields }); + + const normalizedPath = filePath.startsWith('/') ? filePath.substring(1) : filePath; + const encodedPath = normalizedPath.split('/').map(part => encodeURIComponent(part)).join('/'); + const apiFilePath = `/api/files/${encodedPath}`; + + currentDocumentPath.current = ""; + + loadDocument(currentInstance, apiFilePath, true).then(() => { + setIsInitialLoaded(true); + }).catch((error) => { + console.error("📛 초기 마운트 로드 실패:", error); + }); + }, [webViewerInstance.current, instance, filePath, activeTab, isInitialLoaded, allFiles.length]); + + const handleSave = async () => { + const currentInstance = webViewerInstance.current || instance; + if (!currentInstance) return; + + try { + const { documentViewer, annotationManager } = currentInstance.Core; + const doc = documentViewer.getDocument(); + + if (!doc) { + toast.error("문서가 로드되지 않았습니다."); + return; + } + + const formData = await collectFormData(currentInstance); + + const xfdfString = await annotationManager.exportAnnotations(); + const documentData = await doc.getFileData({ + xfdfString, + downloadType: "pdf", + }); + + if (isComplianceTemplate && !surveyData.completed) { + toast.error("준법 설문조사를 먼저 완료해주세요."); + setActiveTab('survey'); + return; + } + + if (onSign) { + await onSign(documentData, { formData, surveyData, signatureFields }); + } else { + toast.success("계약서가 성공적으로 서명되었습니다."); + } + + handleClose(); + } catch (error) { + console.error("📛 서명 저장 실패:", error); + toast.error("서명을 저장하는데 실패했습니다."); + } + }; + + const handleClose = () => { + if (onClose) { + onClose(); } else { - toast.success("계약서가 성공적으로 서명되었습니다."); + setShowDialog(false); } - - handleClose(); - } catch (error) { - console.error("📛 서명 저장 실패:", error); - toast.error("서명을 저장하는데 실패했습니다."); - } -}; - -// 다이얼로그 닫기 핸들러 -const handleClose = () => { - if (onClose) { - onClose(); - } else { - setShowDialog(false); - } -}; + }; -// 동적 설문조사 컴포넌트 -const SurveyComponent = () => { - if (surveyLoading) { - 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">설문조사를 불러오는 중...</p> - </CardContent> - </Card> - </div> - ); - } + // 개선된 SurveyComponent + const SurveyComponent = () => { + const { + control, + watch, + setValue, + getValues, + formState: { errors }, + trigger, + } = useForm<SurveyFormData>({ + defaultValues: {}, + mode: 'onChange' + }); - 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={loadSurveyTemplate} - className="mt-2" - > - 다시 시도 - </Button> - </CardContent> - </Card> - </div> - ); - } + const watchedValues = watch(); + const [uploadedFiles, setUploadedFiles] = useState<Record<number, File[]>>({}); + + // 📊 실시간 진행 상태 계산 + const progressStatus = useMemo(() => { + if (!conditionalHandler || !surveyTemplate) { + return { + visibleQuestions: [], + totalRequired: 0, + completedRequired: 0, + completedQuestionIds: [], + incompleteQuestionIds: [], + progressPercentage: 0, + debugInfo: {} + }; + } - const completedCount = surveyTemplate.questions.filter((q: any) => isSurveyQuestionComplete(q)).length; - const progressPercentage = surveyTemplate.questions.length > 0 ? (completedCount / surveyTemplate.questions.length) * 100 : 0; + console.log('🔄 실시간 프로그레스 재계산 중...'); + console.log('📝 원본 watchedValues:', watchedValues); + + // 현재 답변을 조건부 핸들러가 인식할 수 있는 형태로 변환 + const convertedAnswers: Record<number, any> = {}; + Object.entries(watchedValues).forEach(([questionId, value]) => { + const id = parseInt(questionId); + const convertedValue = { + questionId: id, + answerValue: value?.answerValue || '', + detailText: value?.detailText || '', + otherText: value?.otherText || '', + files: value?.files || [] + }; + + convertedAnswers[id] = convertedValue; + + // 각 질문의 변환 과정 로그 + if (value?.answerValue) { + console.log(`📝 질문 ${id} 변환:`, { + 원본: value, + 변환후: convertedValue + }); + } + }); -const renderSurveyQuestion = (question: any) => { - const answer = surveyAnswers[question.id]; - const isComplete = isSurveyQuestionComplete(question); - - return ( - <div key={question.id} className="mb-6 p-4 border rounded-lg bg-gray-50"> - <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"> - <span className="mr-2 px-2 py-1 bg-blue-100 text-blue-700 rounded text-xs"> - {question.questionNumber} - </span> - {question.questionText} - {question.isRequired && <span className="text-red-500 ml-1">*</span>} - </Label> + console.log('📝 변환된 답변들 최종:', convertedAnswers); + + const result = conditionalHandler.getSimpleProgressStatus(convertedAnswers); + + console.log('📊 실시간 진행 상태 최종 결과:', { + 전체표시질문: result.visibleQuestions.length, + 필수질문수: result.totalRequired, + 완료된필수질문: result.completedRequired, + 진행률: result.progressPercentage, + 완료된질문들: result.completedQuestionIds, + 미완료질문들: result.incompleteQuestionIds, + 기본질문: result.visibleQuestions.filter(q => !q.parentQuestionId).length, + 조건부질문: result.visibleQuestions.filter(q => q.parentQuestionId).length, + 완료된기본질문: result.completedQuestionIds.filter(id => !result.visibleQuestions.find(q => q.id === id)?.parentQuestionId).length, + 완료된조건부질문: result.completedQuestionIds.filter(id => !!result.visibleQuestions.find(q => q.id === id)?.parentQuestionId).length + }); + + // 🚨 조건부 질문들의 답변 상태 특별 점검 + const conditionalQuestions = result.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, + watchedValue: watchedValues[q.id], + convertedAnswer: convertedAnswers[q.id], + hasWatchedAnswer: !!watchedValues[q.id]?.answerValue, + hasConvertedAnswer: !!convertedAnswers[q.id]?.answerValue, + isInRequiredList: result.totalRequired, + isCompleted: result.completedQuestionIds.includes(q.id) + }))); + } + + return result; + }, [conditionalHandler, watchedValues, surveyTemplate]); + + // 🎯 동적 상태 정보 + 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; + + if (surveyLoading) { + 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">설문조사를 불러오는 중...</p> + </CardContent> + </Card> </div> - {isComplete && ( - <CheckCircle2 className="h-5 w-5 text-green-500 ml-2" /> - )} - </div> + ); + } - {question.questionType === 'RADIO' && ( - <RadioGroup - value={answer?.answerValue || ''} - onValueChange={(value) => updateSurveyAnswer(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"> - <Select - value={answer?.answerValue || ''} - onValueChange={(value) => updateSurveyAnswer(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> - - {answer?.answerValue === 'OTHER' && ( + 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={loadSurveyTemplate} + className="mt-2" + > + 다시 시도 + </Button> + </CardContent> + </Card> + </div> + ); + } + + // 🚨 템플릿이 로드되면 모든 질문들의 isRequired 속성 확인 + React.useEffect(() => { + if (surveyTemplate && surveyTemplate.questions) { + console.log('🚨 설문 템플릿의 모든 질문들 isRequired 속성 확인:', surveyTemplate.questions.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 + }))); + + const allQuestions = surveyTemplate.questions.length; + const requiredQuestions = surveyTemplate.questions.filter(q => q.isRequired).length; + const conditionalQuestions = surveyTemplate.questions.filter(q => q.parentQuestionId).length; + const requiredConditionalQuestions = surveyTemplate.questions.filter(q => q.parentQuestionId && q.isRequired).length; + + console.log('📊 템플릿 질문 통계:', { + 전체질문수: allQuestions, + 전체필수질문수: requiredQuestions, + 조건부질문수: conditionalQuestions, + 필수조건부질문수: requiredConditionalQuestions, + 기본질문수: allQuestions - conditionalQuestions, + 필수기본질문수: requiredQuestions - requiredConditionalQuestions + }); + + // 🚨 만약 조건부 질문들이 필수가 아니라면 경고 + if (conditionalQuestions > 0 && requiredConditionalQuestions === 0) { + console.warn('⚠️ 경고: 조건부 질문들이 모두 필수가 아닙니다! 데이터베이스 확인 필요'); + console.warn('조건부 질문들:', surveyTemplate.questions.filter(q => q.parentQuestionId)); + } + } + }, [surveyTemplate]); + + 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); + + // 해당 질문이 조건부 질문인지 확인 + const question = visibleQuestions.find(q => q.id === questionId); + if (question) { + console.log(`📋 질문 ${questionId} 상세 정보:`, { + id: question.id, + questionNumber: question.questionNumber, + isRequired: question.isRequired, + parentQuestionId: question.parentQuestionId, + conditionalValue: question.conditionalValue, + isConditional: !!question.parentQuestionId + }); + } + + setValue(`${questionId}.${field}`, value); + + // setValue 후 현재 값 확인 + setTimeout(() => { + const currentFormValues = getValues(); + console.log(`✅ setValue 후 확인 - 질문 ${questionId}:`, { + 설정한값: value, + 저장된전체값: currentFormValues[questionId], + 전체폼값: currentFormValues + }); + }, 0); + + // 부모 질문의 답변이 변경되면 조건부 자식 질문들 처리 + if (field === 'answerValue' && conditionalHandler) { + const currentValues = getValues(); + const convertedAnswers: Record<number, any> = {}; + + Object.entries(currentValues).forEach(([qId, qValue]) => { + const id = parseInt(qId); + convertedAnswers[id] = { + questionId: id, + answerValue: qValue?.answerValue || '', + detailText: qValue?.detailText || '', + otherText: qValue?.otherText || '', + files: qValue?.files || [] + }; + }); + + // 새로운 답변 반영 + convertedAnswers[questionId] = { + ...convertedAnswers[questionId], + questionId, + [field]: value + }; + + console.log(`🔄 질문 ${questionId}의 답변 변경으로 인한 조건부 질문 처리...`); + console.log(`🔄 변경 후 전체 답변:`, convertedAnswers); + + // 영향받는 자식 질문들의 답변 초기화 + const clearedAnswers = conditionalHandler.clearAffectedChildAnswers(questionId, value, convertedAnswers); + + console.log(`🧹 정리된 답변들:`, clearedAnswers); + + // 삭제된 답변들을 폼에서도 제거 + 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; + }); + } + }); + } + }, [setValue, getValues, conditionalHandler, visibleQuestions]); + + // 🔥 설문조사 완료 핸들러 수정 + const handleSurveyComplete = useCallback(async () => { + console.log('🎯 설문조사 완료 시도'); + + // 이미 제출 중이면 중복 실행 방지 + if (isSubmitting) { + console.log('⚠️ 이미 제출 중...'); + return; + } + + setIsSubmitting(true); + + try { + const currentValues = getValues(); + console.log('📝 현재 폼 값들:', currentValues); + + // 폼 검증 + const isValid = await trigger(); + console.log('🔍 폼 검증 결과:', isValid); + + // 진행 상태 최종 확인 + console.log('📊 최종 진행 상태:', { + totalRequired: progressStatus.totalRequired, + completedRequired: progressStatus.completedRequired, + canComplete, + 완료된질문들: progressStatus.completedQuestionIds, + 미완료질문들: progressStatus.incompleteQuestionIds + }); + + 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 // 디버깅용 추가 정보 + }; + + console.log('📤 서버로 전송할 데이터:', { + contractId: requestData.contractId, + templateId: requestData.templateId, + answersCount: requestData.answers.length, + answers: requestData.answers.map(a => ({ + questionId: a.questionId, + hasAnswer: !!a.answerValue, + hasDetail: !!a.detailText, + hasOther: !!a.otherText, + filesCount: a.files?.length || 0 + })) + }); + + // 제출 중 토스트 표시 + const submitToast = toast.loading('설문조사를 저장하는 중...', { + description: '잠시만 기다려주세요.', + duration: Infinity + }); + + // 서버 액션 호출 + const result = await completeSurvey(requestData); + + // 로딩 토스트 제거 + toast.dismiss(submitToast); + + if (result.success) { + // 클라이언트 상태 업데이트 (기존 로직 유지) + setSurveyData({ + completed: true, + answers: surveyAnswers, + timestamp: new Date().toISOString(), + progressStatus: progressStatus, + totalQuestions: totalVisibleQuestions, + conditionalQuestions: conditionalQuestionCount, + responseId: result.data?.responseId // 서버에서 반환된 응답 ID 저장 + }); + + // 🔥 부모 컴포넌트에 설문조사 완료 알림 + if (onSurveyComplete) { + onSurveyComplete(); + } + + toast.success("🎉 설문조사가 완료되었습니다!", { + description: `총 ${progressStatus.totalRequired}개 필수 질문 완료${hasConditionalQuestions ? ` (조건부 ${conditionalQuestionCount}개 포함)` : ''}`, + icon: <CheckCircle2 className="h-5 w-5 text-green-500" />, + duration: 5000 + }); + + console.log('✅ 설문조사 완료:', { + totalAnswered: surveyAnswers.length, + totalRequired: progressStatus.totalRequired, + conditionalQuestions: conditionalQuestionCount, + responseId: result.data?.responseId + }); + + // 자동으로 메인 탭으로 이동 (선택사항) + 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, + progressStatus, + visibleQuestions, + canComplete, + contractId, + surveyTemplate?.id, + totalVisibleQuestions, + conditionalQuestionCount, + hasConditionalQuestions, + isSubmitting, + setActiveTab, + onSurveyComplete // 🔥 추가 + ]); + + // OTHER 텍스트 입력 컴포넌트 + const OtherTextInput = ({ questionId, fieldName }: { questionId: number; fieldName: string }) => { + const answerValue = useWatch({ + control, + name: `${fieldName}.answerValue` + }); + + if (answerValue !== 'OTHER') return null; + + return ( + <Controller + name={`${fieldName}.otherText`} + control={control} + render={({ field }) => ( <Input + {...field} placeholder="기타 내용을 입력해주세요" - value={answer?.otherText || ''} - onChange={(e) => updateSurveyAnswer(question.id, 'otherText', e.target.value)} className="mt-2" /> )} - </div> - )} - - {question.questionType === 'TEXTAREA' && ( - <Textarea - placeholder="상세한 내용을 입력해주세요" - value={answer?.detailText || ''} - onChange={(e) => updateSurveyAnswer(question.id, 'detailText', e.target.value)} - rows={4} /> - )} - - {question.hasDetailText && answer?.answerValue === 'YES' && ( - <div className="mt-3"> - <Label className="text-sm text-gray-700 mb-2 block">상세 내용을 기술해주세요:</Label> - <Textarea - placeholder="상세한 내용을 입력해주세요" - value={answer?.detailText || ''} - onChange={(e) => updateSurveyAnswer(question.id, 'detailText', e.target.value)} - rows={3} - className="w-full" - /> - </div> - )} - - {question.hasFileUpload && answer?.answerValue === 'YES' && ( - <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) => handleSurveyFileUpload(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> + ); + }; + + return ( + <div className="h-full w-full flex flex-col"> + <Card className="h-full flex flex-col"> + <CardHeader className="flex-shrink-0"> + <CardTitle className="flex items-center justify-between"> + <div className="flex items-center"> + <ClipboardList className="h-5 w-5 mr-2 text-amber-500" /> + {surveyTemplate.name} + {conditionalHandler && ( + <Badge variant="outline" className="ml-2 text-xs"> + 조건부 질문 지원 + </Badge> + )} </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.name}</span> - <span className="text-gray-500">({(file.size / 1024).toFixed(1)} KB)</span> + <div className="text-sm text-gray-600"> + {progressStatus.completedRequired}/{progressStatus.totalRequired} 완료 + </div> + </CardTitle> + + <CardDescription> + {surveyTemplate.description} + + {/* 🎯 동적 질문 수 표시 */} + <div className="mt-2 space-y-1"> + <div className="flex items-center text-sm"> + <span className="text-gray-600"> + 📋 총 {totalVisibleQuestions}개 질문 + {hasConditionalQuestions && ( + <span className="text-blue-600 ml-1"> + (기본 {baseQuestionCount}개 + 조건부 {conditionalQuestionCount}개) + </span> + )} + </span> + </div> + + {hasConditionalQuestions && ( + <div className="text-blue-600 text-sm"> + ⚡ 답변에 따라 {conditionalQuestionCount}개 추가 질문이 나타났습니다 </div> - ))} + )} + </div> + </CardDescription> + + {/* 📊 동적 프로그레스 바 */} + <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-2"> + <div + className="bg-gradient-to-r from-blue-500 to-blue-600 h-2 rounded-full transition-all duration-500 ease-out" + style={{ width: `${progressStatus.progressPercentage}%` }} + /> </div> - )} - </div> - </div> - )} - </div> - ); -}; - return ( - <div className="h-full w-full flex flex-col"> - <Card className="h-full flex flex-col"> - <CardHeader className="flex-shrink-0"> - <CardTitle className="flex items-center justify-between"> - <div className="flex items-center"> - <ClipboardList className="h-5 w-5 mr-2 text-amber-500" /> - {surveyTemplate.name} - </div> - <div className="text-sm text-gray-500"> - {completedCount}/{surveyTemplate.questions.length} 완료 + {/* 세부 진행 상황 */} + {progressStatus.totalRequired > 0 && ( + <div className="text-xs text-gray-500 flex justify-between"> + <span>완료: {progressStatus.completedRequired}개</span> + <span>남은 필수: {progressStatus.totalRequired - progressStatus.completedRequired}개</span> + </div> + )} </div> - </CardTitle> - <CardDescription> - {surveyTemplate.description} - </CardDescription> - - <div className="w-full bg-gray-200 rounded-full h-2"> - <div - className="bg-blue-600 h-2 rounded-full transition-all duration-300" - style={{ width: `${progressPercentage}%` }} - /> - </div> - </CardHeader> - - <CardContent className="flex-1 min-h-0 overflow-y-auto"> - <div 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"> - 본 설문조사는 준법 의무 확인을 위한 필수 절차입니다. 모든 항목을 정확히 작성해주세요. - </p> + </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> - <div className="space-y-4"> - {surveyTemplate.questions.map((question: any) => renderSurveyQuestion(question))} - </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; - <div className="flex justify-end pt-6 border-t"> - <Button - onClick={handleSurveyComplete} - disabled={!isSurveyComplete()} - className="bg-blue-600 hover:bg-blue-700" - > - <CheckCircle2 className="h-4 w-4 mr-2" /> - 설문조사 완료 - </Button> - </div> - </div> - </CardContent> - </Card> - </div> - ); -}; - -// 디버깅을 위한 useEffect -useEffect(() => { - if (isNDATemplate) { - console.log("🔍 NDA 템플릿 디버깅:", { - filePath, - additionalFiles, - allFiles, - activeTab, - isInitialLoaded, - currentDocumentPath: currentDocumentPath.current, - hasWebViewerInstance: !!webViewerInstance.current, - hasParentInstance: !!instance, - signatureFields, - hasSignatureFields, - isAutoSignProcessing, - autoSignError - }); - } -}, [isNDATemplate, filePath, additionalFiles, allFiles, activeTab, isInitialLoaded, signatureFields, hasSignatureFields, isAutoSignProcessing, autoSignError]); + 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> -// ✅ 서명 필드 상태 표시 컴포넌트 -const SignatureFieldsStatus = () => { - if (!hasSignatureFields && !isAutoSignProcessing && !autoSignError) return null; + {/* 질문 타입별 렌더링 (기존 코드와 동일) */} + {/* RADIO 타입 */} + {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> + )} + /> + )} - return ( - <div className="mb-2"> - {isAutoSignProcessing ? ( - <Badge variant="secondary" className="text-xs"> - <Loader2 className="h-3 w-3 mr-1 animate-spin" /> - 서명 필드 생성 중... - </Badge> - ) : autoSignError ? ( - <Badge variant="destructive" className="text-xs bg-red-50 text-red-700 border-red-200"> - <AlertTriangle className="h-3 w-3 mr-1" /> - 자동 생성 실패 - </Badge> - ) : hasSignatureFields ? ( - <Badge variant="outline" className="text-xs bg-blue-50 text-blue-700 border-blue-200"> - <Target className="h-3 w-3 mr-1" /> - {signatureFields.length}개 서명 필드 자동 생성됨 - </Badge> - ) : null} - </div> - ); -}; + {/* DROPDOWN 타입 */} + {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> + )} -// 인라인 뷰어 렌더링 부분 수정 -if (!isOpen && !onClose) { - return ( - <div className="h-full w-full flex flex-col overflow-hidden"> - {allFiles.length > 1 ? ( - <Tabs value={activeTab} onValueChange={handleTabChange} className="h-full flex flex-col"> - <div className="border-b bg-gray-50 px-3 py-2 flex-shrink-0"> - <SignatureFieldsStatus /> - <TabsList className="grid w-full h-8" style={{ gridTemplateColumns: `repeat(${allFiles.length}, 1fr)` }}> - {allFiles.map((file, index) => { - let tabId: string; - if (index === 0) { - tabId = 'main'; - } else if (file.type === 'survey') { - tabId = 'survey'; - } else { - const fileOnlyIndex = allFiles.slice(0, index).filter(f => f.type !== 'survey').length; - tabId = `file-${fileOnlyIndex}`; - } - - return ( - <TabsTrigger key={tabId} value={tabId} className="text-xs"> - <div className="flex items-center space-x-1"> - {file.type === 'survey' ? ( - <ClipboardList className="h-3 w-3" /> - ) : ( - <FileText className="h-3 w-3" /> + {/* TEXTAREA 타입 */} + {question.questionType === 'TEXTAREA' && ( + <Controller + name={`${fieldName}.detailText`} + control={control} + rules={{ required: question.isRequired ? '필수 항목입니다.' : false }} + render={({ field }) => ( + <Textarea + {...field} + placeholder="상세한 내용을 입력해주세요" + rows={4} + /> + )} + /> )} - <span className="truncate">{file.name}</span> - {file.type === 'survey' && surveyData.completed && ( - <Badge variant="secondary" className="ml-1 h-4 px-1 text-xs">완료</Badge> + + {/* 상세 텍스트 입력 */} + {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.name}</span> + <span className="text-gray-500">({(file.size / 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> - </TabsTrigger> - ); - })} - </TabsList> - </div> - - <div className="flex-1 min-h-0 overflow-hidden relative"> - <div - className={`absolute inset-0 p-3 ${activeTab === 'survey' ? 'block' : 'hidden'}`} - > - <SurveyComponent /> - </div> - - <div - className={`absolute inset-0 ${activeTab !== 'survey' ? 'block' : 'hidden'}`} - > - {/* ✅ 수정: 동일한 구조로 통일하고 스크롤 활성화 */} - <div className="w-full h-full overflow-auto"> - <div - ref={viewer} - className="w-full h-full min-h-[400px]" - style={{ - position: 'relative', - // ✅ WebViewer가 스크롤을 제어하도록 설정 - overflow: 'visible' - }} - > - {fileLoading && ( - <div className="absolute inset-0 flex flex-col items-center justify-center bg-white z-10"> - <Loader2 className="h-8 w-8 text-blue-500 animate-spin mb-4" /> - <p className="text-sm text-muted-foreground">문서 로딩 중...</p> - </div> - )} - </div> + ); + })} </div> - </div> - </div> - </Tabs> - ) : ( - // ✅ 수정: Tabs가 없는 경우도 동일한 구조로 변경 - <div className="h-full w-full flex flex-col"> - <div className="flex-shrink-0 p-2"> - <SignatureFieldsStatus /> - </div> - <div className="flex-1 min-h-0 overflow-hidden relative"> - <div className="absolute inset-0"> - <div className="w-full h-full overflow-auto"> - <div - ref={viewer} - className="w-full h-full min-h-[400px]" - style={{ - position: 'relative', - // ✅ WebViewer가 스크롤을 제어하도록 설정 - overflow: 'visible' - }} - > - {fileLoading && ( - <div className="absolute inset-0 flex flex-col items-center justify-center bg-white z-10"> - <Loader2 className="h-8 w-8 text-blue-500 animate-spin mb-4" /> - <p className="text-sm text-muted-foreground">문서 로딩 중...</p> - </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> - </div> - </div> - </div> - )} - </div> - ); -} + </form> + </CardContent> + </Card> + </div> + ); + }; + + // 🔥 서명 상태 표시 컴포넌트 개선 + const SignatureFieldsStatus = () => { + if (!hasSignatureFields && !isAutoSignProcessing && !autoSignError && !hasValidSignature) return null; -// 다이얼로그 뷰어 렌더링 부분도 동일하게 수정 -return ( - <Dialog open={showDialog} onOpenChange={handleClose}> - <DialogContent className="w-[90vw] max-w-6xl h-[90vh] flex flex-col p-0"> - <DialogHeader className="px-6 py-4 border-b flex-shrink-0"> - <DialogTitle className="flex items-center justify-between"> - <span>기본계약서 서명</span> - <SignatureFieldsStatus /> - </DialogTitle> - <DialogDescription> - 계약서를 확인하고 서명을 진행해주세요. - {isComplianceTemplate && ( - <span className="block mt-1 text-amber-600">📋 준법 설문조사를 먼저 완료해주세요.</span> - )} - {isNDATemplate && additionalFiles.length > 0 && ( - <span className="block mt-1 text-blue-600">📎 첨부서류 {additionalFiles.length}개를 각 탭에서 확인해주세요.</span> - )} - {hasSignatureFields && ( - <span className="block mt-1 text-green-600"> - 🎯 서명 위치가 자동으로 감지되었습니다. - {signatureFields.some(f => f.includes('_text')) && ( - <span className="block text-sm text-amber-600"> - 💡 빨간색 텍스트로 표시된 영역을 찾아 서명해주세요. - </span> - )} - {signatureFields.some(f => f.startsWith('default_signature_')) && !signatureFields.some(f => f.includes('_text')) && ( - <span className="block text-sm text-amber-600"> - 💡 마지막 페이지 하단의 핑크색 영역에서 서명해주세요. - </span> - )} - </span> - )} - {autoSignError && ( - <span className="block mt-1 text-red-600">⚠️ 자동 서명 필드 생성 실패 - 수동으로 서명 위치를 클릭해주세요.</span> - )} - </DialogDescription> - </DialogHeader> + return ( + <div className="mb-2 flex items-center space-x-2"> + {isAutoSignProcessing ? ( + <Badge variant="secondary" className="text-xs"> + <Loader2 className="h-3 w-3 mr-1 animate-spin" /> + 서명 필드 생성 중... + </Badge> + ) : autoSignError ? ( + <Badge variant="destructive" className="text-xs bg-red-50 text-red-700 border-red-200"> + <AlertTriangle className="h-3 w-3 mr-1" /> + 자동 생성 실패 + </Badge> + ) : hasSignatureFields ? ( + <Badge variant="outline" className="text-xs bg-blue-50 text-blue-700 border-blue-200"> + <Target className="h-3 w-3 mr-1" /> + {signatureFields.length}개 서명 필드 자동 생성됨 + </Badge> + ) : null} + + {/* 🔥 서명 완료 상태 표시 */} + {hasValidSignature && ( + <Badge variant="outline" className="text-xs bg-green-50 text-green-700 border-green-200"> + <CheckCircle2 className="h-3 w-3 mr-1" /> + 서명 완료됨 + </Badge> + )} + </div> + ); + }; - <div className="flex-1 min-h-0 overflow-hidden"> + // 인라인 뷰어 렌더링 + if (!isOpen && !onClose) { + return ( + <div className="h-full w-full flex flex-col overflow-hidden"> {allFiles.length > 1 ? ( <Tabs value={activeTab} onValueChange={handleTabChange} className="h-full flex flex-col"> <div className="border-b bg-gray-50 px-3 py-2 flex-shrink-0"> + <SignatureFieldsStatus /> <TabsList className="grid w-full h-8" style={{ gridTemplateColumns: `repeat(${allFiles.length}, 1fr)` }}> {allFiles.map((file, index) => { - const tabId = index === 0 ? 'main' : file.type === 'survey' ? 'survey' : `file-${index}`; + let tabId: string; + if (index === 0) { + tabId = 'main'; + } else if (file.type === 'survey') { + tabId = 'survey'; + } else { + const fileOnlyIndex = allFiles.slice(0, index).filter(f => f.type !== 'survey').length; + tabId = `file-${fileOnlyIndex}`; + } + return ( <TabsTrigger key={tabId} value={tabId} className="text-xs"> <div className="flex items-center space-x-1"> - {file.type === 'survey' ? <ClipboardList className="h-3 w-3" /> : <FileText className="h-3 w-3" />} + {file.type === 'survey' ? ( + <ClipboardList className="h-3 w-3" /> + ) : ( + <FileText className="h-3 w-3" /> + )} <span className="truncate">{file.name}</span> {file.type === 'survey' && surveyData.completed && ( <Badge variant="secondary" className="ml-1 h-4 px-1 text-xs">완료</Badge> @@ -1520,17 +1901,20 @@ return ( </div> <div className="flex-1 min-h-0 overflow-hidden relative"> - <div className={`absolute inset-0 p-3 ${activeTab === 'survey' ? 'block' : 'hidden'}`}> + <div + className={`absolute inset-0 p-3 ${activeTab === 'survey' ? 'block' : 'hidden'}`} + > <SurveyComponent /> </div> - <div className={`absolute inset-0 ${activeTab !== 'survey' ? 'block' : 'hidden'}`}> - {/* ✅ 수정: 스크롤 활성화 */} + <div + className={`absolute inset-0 ${activeTab !== 'survey' ? 'block' : 'hidden'}`} + > <div className="w-full h-full overflow-auto"> - <div - ref={viewer} + <div + ref={viewer} className="w-full h-full min-h-[400px]" - style={{ + style={{ position: 'relative', overflow: 'visible' }} @@ -1547,15 +1931,17 @@ return ( </div> </Tabs> ) : ( - // ✅ 수정: 다이얼로그에서 뷰어만 있는 경우도 동일한 구조 - <div className="h-full flex flex-col"> + <div className="h-full w-full flex flex-col"> + <div className="flex-shrink-0 p-2"> + <SignatureFieldsStatus /> + </div> <div className="flex-1 min-h-0 overflow-hidden relative"> <div className="absolute inset-0"> <div className="w-full h-full overflow-auto"> - <div - ref={viewer} + <div + ref={viewer} className="w-full h-full min-h-[400px]" - style={{ + style={{ position: 'relative', overflow: 'visible' }} @@ -1573,133 +1959,120 @@ return ( </div> )} </div> + ); + } - <DialogFooter className="px-6 py-4 border-t bg-white flex-shrink-0"> - <Button variant="outline" onClick={handleClose} disabled={fileLoading}>취소</Button> - <Button onClick={handleSave} disabled={fileLoading || isAutoSignProcessing}> - <FileSignature className="h-4 w-4 mr-2" /> - 서명 완료 - </Button> - </DialogFooter> - </DialogContent> - </Dialog> -); - -// 다이얼로그 뷰어 렌더링 -return ( - <Dialog open={showDialog} onOpenChange={handleClose}> - <DialogContent className="w-[90vw] max-w-6xl h-[90vh] flex flex-col p-0"> - <DialogHeader className="px-6 py-4 border-b flex-shrink-0"> - <DialogTitle className="flex items-center justify-between"> - <span>기본계약서 서명</span> - <SignatureFieldsStatus /> - </DialogTitle> - <DialogDescription> - 계약서를 확인하고 서명을 진행해주세요. - {isComplianceTemplate && ( - <span className="block mt-1 text-amber-600">📋 준법 설문조사를 먼저 완료해주세요.</span> - )} - {isNDATemplate && additionalFiles.length > 0 && ( - <span className="block mt-1 text-blue-600">📎 첨부서류 {additionalFiles.length}개를 각 탭에서 확인해주세요.</span> - )} - {hasSignatureFields && ( - <span className="block mt-1 text-green-600"> - 🎯 서명 위치가 자동으로 감지되었습니다. - {signatureFields.some(f => f.includes('_text')) && ( - <span className="block text-sm text-amber-600"> - 💡 빨간색 텍스트로 표시된 영역을 찾아 서명해주세요. - </span> - )} - {signatureFields.some(f => f.startsWith('default_signature_')) && !signatureFields.some(f => f.includes('_text')) && ( - <span className="block text-sm text-amber-600"> - 💡 마지막 페이지 하단의 핑크색 영역에서 서명해주세요. - </span> - )} - </span> - )} - {autoSignError && ( - <span className="block mt-1 text-red-600">⚠️ 자동 서명 필드 생성 실패 - 수동으로 서명 위치를 클릭해주세요.</span> - )} - </DialogDescription> - </DialogHeader> - - <div className="flex-1 min-h-0 overflow-hidden"> - {allFiles.length > 1 ? ( - <Tabs value={activeTab} onValueChange={handleTabChange} className="h-full flex flex-col"> - <div className="border-b bg-gray-50 px-3 py-2 flex-shrink-0"> - <TabsList className="grid w-full h-8" style={{ gridTemplateColumns: `repeat(${allFiles.length}, 1fr)` }}> - {allFiles.map((file, index) => { - const tabId = index === 0 ? 'main' : file.type === 'survey' ? 'survey' : `file-${index}`; - return ( - <TabsTrigger key={tabId} value={tabId} className="text-xs"> - <div className="flex items-center space-x-1"> - {file.type === 'survey' ? <ClipboardList className="h-3 w-3" /> : <FileText className="h-3 w-3" />} - <span className="truncate">{file.name}</span> - {file.type === 'survey' && surveyData.completed && ( - <Badge variant="secondary" className="ml-1 h-4 px-1 text-xs">완료</Badge> - )} - </div> - </TabsTrigger> - ); - })} - </TabsList> - </div> - - <div className="flex-1 min-h-0 overflow-hidden relative"> - <div className={`absolute inset-0 p-3 ${activeTab === 'survey' ? 'block' : 'hidden'}`}> - <SurveyComponent /> + // 다이얼로그 뷰어 렌더링 + return ( + <Dialog open={showDialog} onOpenChange={handleClose}> + <DialogContent className="w-[90vw] max-w-6xl h-[90vh] flex flex-col p-0"> + <DialogHeader className="px-6 py-4 border-b flex-shrink-0"> + <DialogTitle className="flex items-center justify-between"> + <span>기본계약서 서명</span> + <SignatureFieldsStatus /> + </DialogTitle> + <DialogDescription> + 계약서를 확인하고 서명을 진행해주세요. + {isComplianceTemplate && ( + <span className="block mt-1 text-amber-600">📋 준법 설문조사를 먼저 완료해주세요.</span> + )} + {hasSignatureFields && ( + <span className="block mt-1 text-green-600"> + 🎯 서명 위치가 자동으로 감지되었습니다. + </span> + )} + {/* 🔥 서명 완료 상태 안내 */} + {hasValidSignature && ( + <span className="block mt-1 text-green-600"> + ✅ 서명이 완료되었습니다. + </span> + )} + </DialogDescription> + </DialogHeader> + + <div className="flex-1 min-h-0 overflow-hidden"> + {allFiles.length > 1 ? ( + <Tabs value={activeTab} onValueChange={handleTabChange} className="h-full flex flex-col"> + <div className="border-b bg-gray-50 px-3 py-2 flex-shrink-0"> + <TabsList className="grid w-full h-8" style={{ gridTemplateColumns: `repeat(${allFiles.length}, 1fr)` }}> + {allFiles.map((file, index) => { + const tabId = index === 0 ? 'main' : file.type === 'survey' ? 'survey' : `file-${index}`; + return ( + <TabsTrigger key={tabId} value={tabId} className="text-xs"> + <div className="flex items-center space-x-1"> + {file.type === 'survey' ? <ClipboardList className="h-3 w-3" /> : <FileText className="h-3 w-3" />} + <span className="truncate">{file.name}</span> + {file.type === 'survey' && surveyData.completed && ( + <Badge variant="secondary" className="ml-1 h-4 px-1 text-xs">완료</Badge> + )} + </div> + </TabsTrigger> + ); + })} + </TabsList> </div> - <div className={`absolute inset-0 ${activeTab !== 'survey' ? 'block' : 'hidden'}`}> - <div - ref={viewer} - className="w-full h-full" - style={{ position: 'relative', minHeight: '400px' }} - > - {fileLoading && ( - <div className="absolute inset-0 flex flex-col items-center justify-center bg-white z-10"> - <Loader2 className="h-8 w-8 text-blue-500 animate-spin mb-4" /> - <p className="text-sm text-muted-foreground">문서 로딩 중...</p> + <div className="flex-1 min-h-0 overflow-hidden relative"> + <div className={`absolute inset-0 p-3 ${activeTab === 'survey' ? 'block' : 'hidden'}`}> + <SurveyComponent /> + </div> + + <div className={`absolute inset-0 ${activeTab !== 'survey' ? 'block' : 'hidden'}`}> + <div className="w-full h-full overflow-auto"> + <div + ref={viewer} + className="w-full h-full min-h-[400px]" + style={{ + position: 'relative', + overflow: 'visible' + }} + > + {fileLoading && ( + <div className="absolute inset-0 flex flex-col items-center justify-center bg-white z-10"> + <Loader2 className="h-8 w-8 text-blue-500 animate-spin mb-4" /> + <p className="text-sm text-muted-foreground">문서 로딩 중...</p> + </div> + )} </div> - )} + </div> </div> </div> - </div> - </Tabs> - ) : ( - <div className="h-full relative"> - <div - ref={viewer} - className="absolute inset-0" - style={{ position: 'relative', minHeight: '400px' }} - > - {fileLoading && ( - <div className="absolute inset-0 flex flex-col items-center justify-center bg-white z-10"> - <Loader2 className="h-8 w-8 text-blue-500 animate-spin mb-4" /> - <p className="text-sm text-muted-foreground">문서 로딩 중...</p> + </Tabs> + ) : ( + <div className="h-full flex flex-col"> + <div className="flex-1 min-h-0 overflow-hidden relative"> + <div className="absolute inset-0"> + <div className="w-full h-full overflow-auto"> + <div + ref={viewer} + className="w-full h-full min-h-[400px]" + style={{ + position: 'relative', + overflow: 'visible' + }} + > + {fileLoading && ( + <div className="absolute inset-0 flex flex-col items-center justify-center bg-white z-10"> + <Loader2 className="h-8 w-8 text-blue-500 animate-spin mb-4" /> + <p className="text-sm text-muted-foreground">문서 로딩 중...</p> + </div> + )} + </div> + </div> </div> - )} + </div> </div> - </div> - )} - </div> - - <DialogFooter className="px-6 py-4 border-t bg-white flex-shrink-0"> - <Button variant="outline" onClick={handleClose} disabled={fileLoading}>취소</Button> - <Button onClick={handleSave} disabled={fileLoading || isAutoSignProcessing}> - <FileSignature className="h-4 w-4 mr-2" /> - 서명 완료 - </Button> - </DialogFooter> - </DialogContent> - </Dialog> -); -} + )} + </div> -// WebViewer 정리 함수 -const cleanupHtmlStyle = () => { -const elements = document.querySelectorAll('.Document_container'); -elements.forEach((elem) => { - elem.remove(); -}); -};
\ No newline at end of file + <DialogFooter className="px-6 py-4 border-t bg-white flex-shrink-0"> + <Button variant="outline" onClick={handleClose} disabled={fileLoading}>취소</Button> + <Button onClick={handleSave} disabled={fileLoading || isAutoSignProcessing}> + <FileSignature className="h-4 w-4 mr-2" /> + 서명 완료 + </Button> + </DialogFooter> + </DialogContent> + </Dialog> + ); +}
\ No newline at end of file diff --git a/lib/compliance/table/compliance-template-create-dialog.tsx b/lib/compliance/table/compliance-template-create-dialog.tsx index 4d16b0a1..5b7e1092 100644 --- a/lib/compliance/table/compliance-template-create-dialog.tsx +++ b/lib/compliance/table/compliance-template-create-dialog.tsx @@ -106,7 +106,7 @@ export function ComplianceTemplateCreateDialog() { <FormLabel>템플릿명 *</FormLabel> <FormControl> <Input - placeholder="예: ESG 준법 설문조사" + placeholder="예: 준법 설문조사" {...field} /> </FormControl> diff --git a/lib/dashboard/dashboard-client.tsx b/lib/dashboard/dashboard-client.tsx index 398a18f2..fef279e5 100644 --- a/lib/dashboard/dashboard-client.tsx +++ b/lib/dashboard/dashboard-client.tsx @@ -68,7 +68,7 @@ export function DashboardClient({ initialData }: DashboardClientProps) { <div className="flex items-center justify-between"> <div> <h2 className="text-2xl font-bold tracking-tight"> - {getDomainDisplayName(domain)} 대시보드 + {getDomainDisplayName(domain)} 대시보드 </h2> {/* <p className="text-muted-foreground"> {domain === "partners" diff --git a/lib/items-tech/service.ts b/lib/items-tech/service.ts index e0896144..59aa7c6e 100644 --- a/lib/items-tech/service.ts +++ b/lib/items-tech/service.ts @@ -1460,6 +1460,102 @@ export async function getShipTypes() { }
}
+/**
+ * 조선 아이템에서 IM으로 시작하는 최대 itemCode 번호 조회
+ */
+export async function getMaxShipbuildingIMCode(): Promise<number> {
+ unstable_noStore();
+
+ try {
+ const result = await db
+ .select({ itemCode: itemShipbuilding.itemCode })
+ .from(itemShipbuilding)
+ .where(sql`${itemShipbuilding.itemCode} LIKE 'IM%'`)
+ .orderBy(desc(itemShipbuilding.itemCode))
+ .limit(1);
+
+ if (result.length === 0) {
+ return 0; // IM 코드가 없으면 0부터 시작
+ }
+
+ const lastCode = result[0].itemCode;
+ const match = lastCode?.match(/^IM(\d+)$/);
+
+ if (match) {
+ return parseInt(match[1], 10);
+ }
+
+ return 0;
+ } catch (err) {
+ console.error("조선 IM 코드 조회 오류:", err);
+ return 0;
+ }
+}
+
+/**
+ * 해양 TOP 아이템에서 IM으로 시작하는 최대 itemCode 번호 조회
+ */
+export async function getMaxOffshoreTopIMCode(): Promise<number> {
+ unstable_noStore();
+
+ try {
+ const result = await db
+ .select({ itemCode: itemOffshoreTop.itemCode })
+ .from(itemOffshoreTop)
+ .where(sql`${itemOffshoreTop.itemCode} LIKE 'IM%'`)
+ .orderBy(desc(itemOffshoreTop.itemCode))
+ .limit(1);
+
+ if (result.length === 0) {
+ return 0; // IM 코드가 없으면 0부터 시작
+ }
+
+ const lastCode = result[0].itemCode;
+ const match = lastCode?.match(/^IM(\d+)$/);
+
+ if (match) {
+ return parseInt(match[1], 10);
+ }
+
+ return 0;
+ } catch (err) {
+ console.error("해양 TOP IM 코드 조회 오류:", err);
+ return 0;
+ }
+}
+
+/**
+ * 해양 HULL 아이템에서 IM으로 시작하는 최대 itemCode 번호 조회
+ */
+export async function getMaxOffshoreHullIMCode(): Promise<number> {
+ unstable_noStore();
+
+ try {
+ const result = await db
+ .select({ itemCode: itemOffshoreHull.itemCode })
+ .from(itemOffshoreHull)
+ .where(sql`${itemOffshoreHull.itemCode} LIKE 'IM%'`)
+ .orderBy(desc(itemOffshoreHull.itemCode))
+ .limit(1);
+
+ if (result.length === 0) {
+ return 0; // IM 코드가 없으면 0부터 시작
+ }
+
+ const lastCode = result[0].itemCode;
+ const match = lastCode?.match(/^IM(\d+)$/);
+
+ if (match) {
+ return parseInt(match[1], 10);
+ }
+
+ return 0;
+ } catch (err) {
+ console.error("해양 HULL IM 코드 조회 오류:", err);
+ return 0;
+ }
+}
+
// -----------------------------------------------------------
// 기술영업을 위한 로직 끝
// -----------------------------------------------------------
\ No newline at end of file diff --git a/lib/items-tech/table/hull/import-item-handler.tsx b/lib/items-tech/table/hull/import-item-handler.tsx index 9090dab1..90ff47ae 100644 --- a/lib/items-tech/table/hull/import-item-handler.tsx +++ b/lib/items-tech/table/hull/import-item-handler.tsx @@ -1,7 +1,8 @@ "use client"
import { z } from "zod"
-import { createOffshoreHullItem } from "../../service"
+import { createOffshoreHullItem, getMaxOffshoreHullIMCode } from "../../service"
+import { splitItemCodes, createErrorExcelFile, ExtendedProcessResult, processEmptyItemCodes } from "../../utils/import-utils"
// 해양 HULL 기능(공종) 유형 enum
const HULL_WORK_TYPES = ["HA", "HE", "HH", "HM", "HO", "HP", "NC"] as const;
@@ -16,23 +17,25 @@ const itemSchema = z.object({ subItemList: z.string().nullable().optional(),
});
-interface ProcessResult {
- successCount: number;
- errorCount: number;
- errors: Array<{ row: number; message: string }>;
-}
-
/**
* Excel 파일에서 가져온 해양 HULL 아이템 데이터 처리하는 함수
*/
export async function processHullFileImport(
jsonData: Record<string, unknown>[],
progressCallback?: (current: number, total: number) => void
-): Promise<ProcessResult> {
+): Promise<ExtendedProcessResult> {
// 결과 카운터 초기화
let successCount = 0;
let errorCount = 0;
- const errors: Array<{ row: number; message: string }> = [];
+ const errors: Array<{
+ row: number;
+ message: string;
+ itemCode?: string;
+ workType?: string;
+ originalData?: Record<string, any>;
+ }> = [];
+ const processedItemCodes: string[] = [];
+ const duplicateItemCodes: string[] = [];
// 빈 행 등 필터링
const dataRows = jsonData.filter(row => {
@@ -45,9 +48,19 @@ export async function processHullFileImport( // 데이터 행이 없으면 빈 결과 반환
if (dataRows.length === 0) {
- return { successCount: 0, errorCount: 0, errors: [] };
+ return {
+ successCount: 0,
+ errorCount: 0,
+ errors: [],
+ processedItemCodes: [],
+ duplicateItemCodes: []
+ };
}
+ // 기존 IM 코드의 최대 번호 조회
+ const maxIMCode = await getMaxOffshoreHullIMCode();
+ let nextIMNumber = maxIMCode + 1;
+
// 각 행에 대해 처리
for (let i = 0; i < dataRows.length; i++) {
const row = dataRows[i];
@@ -81,33 +94,76 @@ export async function processHullFileImport( err => `${err.path.join('.')}: ${err.message}`
).join(', ');
- errors.push({ row: rowIndex, message: errorMessage });
+ errors.push({
+ row: rowIndex,
+ message: errorMessage,
+ originalData: cleanedRow
+ });
errorCount++;
continue;
}
- // 해양 HULL 아이템 생성
- const result = await createOffshoreHullItem({
- itemCode: cleanedRow.itemCode,
- workType: cleanedRow.workType as "HA" | "HE" | "HH" | "HM" | "HO" | "HP" | "NC",
- itemList: cleanedRow.itemList,
- subItemList: cleanedRow.subItemList,
- });
+ // itemCode 분할 처리
+ const rawItemCodes = splitItemCodes(cleanedRow.itemCode);
- if (result.success) {
- successCount++;
- } else {
- errors.push({
- row: rowIndex,
- message: result.message || result.error || "알 수 없는 오류"
- });
- errorCount++;
+ // 빈 itemCode 처리 (임시 코드 생성)
+ const { codes: itemCodes, nextNumber } = processEmptyItemCodes(rawItemCodes, nextIMNumber);
+ nextIMNumber = nextNumber;
+
+ // 각 itemCode에 대해 개별 처리
+ let rowSuccessCount = 0;
+ let rowErrorCount = 0;
+
+ for (const singleItemCode of itemCodes) {
+ try {
+ // 해양 HULL 아이템 생성
+ const result = await createOffshoreHullItem({
+ itemCode: singleItemCode,
+ workType: cleanedRow.workType as "HA" | "HE" | "HH" | "HM" | "HO" | "HP" | "NC",
+ itemList: cleanedRow.itemList,
+ subItemList: cleanedRow.subItemList,
+ });
+
+ if (result.success) {
+ rowSuccessCount++;
+ processedItemCodes.push(singleItemCode);
+ } else {
+ rowErrorCount++;
+ if (result.message?.includes('중복') || result.error?.includes('중복')) {
+ duplicateItemCodes.push(singleItemCode);
+ }
+
+ errors.push({
+ row: rowIndex,
+ message: `${singleItemCode}: ${result.message || result.error || "알 수 없는 오류"}`,
+ itemCode: singleItemCode,
+ workType: cleanedRow.workType,
+ originalData: cleanedRow
+ });
+ }
+ } catch (error) {
+ rowErrorCount++;
+ console.error(`${rowIndex}행 ${singleItemCode} 처리 오류:`, error);
+ errors.push({
+ row: rowIndex,
+ message: `${singleItemCode}: ${error instanceof Error ? error.message : "알 수 없는 오류"}`,
+ itemCode: singleItemCode,
+ workType: cleanedRow.workType,
+ originalData: cleanedRow
+ });
+ }
}
+
+ // 행별 성공/실패 카운트 업데이트
+ successCount += rowSuccessCount;
+ errorCount += rowErrorCount;
+
} catch (error) {
console.error(`${rowIndex}행 처리 오류:`, error);
errors.push({
row: rowIndex,
- message: error instanceof Error ? error.message : "알 수 없는 오류"
+ message: error instanceof Error ? error.message : "알 수 없는 오류",
+ originalData: row as Record<string, any>
});
errorCount++;
}
@@ -118,10 +174,17 @@ export async function processHullFileImport( }
}
+ // 에러가 있으면 Excel 파일 생성
+ if (errors.length > 0) {
+ await createErrorExcelFile(errors, 'hull');
+ }
+
// 처리 결과 반환
return {
successCount,
errorCount,
- errors: errors.length > 0 ? errors : []
+ errors,
+ processedItemCodes,
+ duplicateItemCodes
};
}
diff --git a/lib/items-tech/table/import-excel-button.tsx b/lib/items-tech/table/import-excel-button.tsx index f8ba9f6d..c0c37b75 100644 --- a/lib/items-tech/table/import-excel-button.tsx +++ b/lib/items-tech/table/import-excel-button.tsx @@ -19,7 +19,7 @@ import { processFileImport } from "./ship/import-item-handler" import { processTopFileImport } from "./top/import-item-handler"
import { processHullFileImport } from "./hull/import-item-handler"
import { decryptWithServerAction } from "@/components/drm/drmUtils"
-
+import { ExtendedProcessResult } from "../utils/import-utils"
// 선박 아이템 타입
type ItemType = "ship" | "top" | "hull";
@@ -58,7 +58,6 @@ export function ImportItemButton({ itemType, onSuccess }: ImportItemButtonProps) setError(null);
};
-
// 데이터 가져오기 처리
const handleImport = async () => {
if (!file) {
@@ -84,6 +83,7 @@ export function ImportItemButton({ itemType, onSuccess }: ImportItemButtonProps) // 복호화 실패 시 원본 파일 사용
arrayBuffer = await file.arrayBuffer();
}
+
// ExcelJS 워크북 로드
const workbook = new ExcelJS.Workbook();
await workbook.xlsx.load(arrayBuffer);
@@ -93,12 +93,12 @@ export function ImportItemButton({ itemType, onSuccess }: ImportItemButtonProps) if (!worksheet) {
throw new Error("Excel 파일에 워크시트가 없습니다.");
}
+
// 헤더 행 찾기
let headerRowIndex = 1;
let headerRow: ExcelJS.Row | undefined;
let headerValues: (string | null)[] = [];
-
worksheet.eachRow((row, rowNumber) => {
const values = row.values as (string | null)[];
if (!headerRow && values.some(v => v === "자재 그룹" || v === "자재 코드" || v === "itemCode" || v === "item_code")) {
@@ -172,7 +172,7 @@ export function ImportItemButton({ itemType, onSuccess }: ImportItemButtonProps) };
// 선택된 타입에 따라 적절한 프로세스 함수 호출
- let result: { successCount: number; errorCount: number; errors?: Array<{ row: number; message: string }> };
+ let result: ExtendedProcessResult;
if (itemType === "top") {
result = await processTopFileImport(dataRows, updateProgress);
} else if (itemType === "hull") {
@@ -181,15 +181,26 @@ export function ImportItemButton({ itemType, onSuccess }: ImportItemButtonProps) result = await processFileImport(dataRows, updateProgress);
}
- toast.success(`${result.successCount}개의 ${ITEM_TYPE_NAMES[itemType]}이(가) 성공적으로 가져와졌습니다.`);
+ // 성공 메시지 표시
+ const successMessage = `${result.successCount}개의 ${ITEM_TYPE_NAMES[itemType]}이(가) 성공적으로 가져와졌습니다.`;
+ if (result.processedItemCodes.length > 0) {
+ toast.success(successMessage);
+ }
+ // 에러 처리 및 상세 정보 표시
if (result.errorCount > 0) {
- const errorDetails = result.errors?.map((error: { row: number; message: string; itemCode?: string; workType?: string }) =>
+ const errorDetails = result.errors?.map((error) =>
`행 ${error.row}: ${error.itemCode || '알 수 없음'} (${error.workType || '알 수 없음'}) - ${error.message}`
).join('\n') || '오류 정보를 가져올 수 없습니다.';
console.error('Import 오류 상세:', errorDetails);
- toast.error(`${result.errorCount}개의 항목 처리 실패. 콘솔에서 상세 내용을 확인하세요.`);
+
+ // 중복된 아이템코드가 있는 경우 별도 메시지
+ if (result.duplicateItemCodes.length > 0) {
+ toast.error(`${result.duplicateItemCodes.length}개의 중복 아이템코드가 발견되었습니다. 오류 Excel 파일을 확인하세요.`);
+ } else {
+ toast.error(`${result.errorCount}개의 항목 처리 실패. 오류 Excel 파일을 확인하세요.`);
+ }
}
// 상태 초기화 및 다이얼로그 닫기
@@ -208,8 +219,6 @@ export function ImportItemButton({ itemType, onSuccess }: ImportItemButtonProps) }
};
-
-
// 다이얼로그 열기/닫기 핸들러
const handleOpenChange = (newOpen: boolean) => {
if (!newOpen) {
@@ -245,6 +254,10 @@ export function ImportItemButton({ itemType, onSuccess }: ImportItemButtonProps) {ITEM_TYPE_NAMES[itemType]}을 Excel 파일에서 가져옵니다.
<br />
올바른 형식의 Excel 파일(.xlsx)을 업로드하세요.
+ <br />
+ <strong>참고:</strong> 아이템코드는 공백이나 콤마로 구분하여 여러 개를 입력할 수 있습니다.
+ <br />
+ <strong>자동 코드 생성:</strong> 아이템코드가 비어있으면 IM0001부터 자동으로 생성됩니다.
</DialogDescription>
</DialogHeader>
diff --git a/lib/items-tech/table/ship/import-item-handler.tsx b/lib/items-tech/table/ship/import-item-handler.tsx index b0f475ff..e95d1987 100644 --- a/lib/items-tech/table/ship/import-item-handler.tsx +++ b/lib/items-tech/table/ship/import-item-handler.tsx @@ -1,7 +1,8 @@ "use client"
import { z } from "zod"
-import { createShipbuildingImportItem } from "../../service" // 아이템 생성 서버 액션
+import { createShipbuildingImportItem, getMaxShipbuildingIMCode } from "../../service" // 아이템 생성 서버 액션
+import { splitItemCodes, createErrorExcelFile, ExtendedProcessResult, processEmptyItemCodes } from "../../utils/import-utils"
// 아이템 데이터 검증을 위한 Zod 스키마
const itemSchema = z.object({
@@ -13,23 +14,25 @@ const itemSchema = z.object({ itemList: z.string().nullable().optional(),
});
-interface ProcessResult {
- successCount: number;
- errorCount: number;
- errors: Array<{ row: number; message: string }>;
-}
-
/**
* Excel 파일에서 가져온 조선 아이템 데이터 처리하는 함수
*/
export async function processFileImport(
jsonData: Record<string, unknown>[],
progressCallback?: (current: number, total: number) => void
-): Promise<ProcessResult> {
+): Promise<ExtendedProcessResult> {
// 결과 카운터 초기화
let successCount = 0;
let errorCount = 0;
- const errors: Array<{ row: number; message: string }> = [];
+ const errors: Array<{
+ row: number;
+ message: string;
+ itemCode?: string;
+ workType?: string;
+ originalData?: Record<string, any>;
+ }> = [];
+ const processedItemCodes: string[] = [];
+ const duplicateItemCodes: string[] = [];
// 빈 행 등 필터링
const dataRows = jsonData.filter(row => {
@@ -42,9 +45,19 @@ export async function processFileImport( // 데이터 행이 없으면 빈 결과 반환
if (dataRows.length === 0) {
- return { successCount: 0, errorCount: 0, errors: [] };
+ return {
+ successCount: 0,
+ errorCount: 0,
+ errors: [],
+ processedItemCodes: [],
+ duplicateItemCodes: []
+ };
}
+ // 기존 IM 코드의 최대 번호 조회
+ const maxIMCode = await getMaxShipbuildingIMCode();
+ let nextIMNumber = maxIMCode + 1;
+
// 각 행에 대해 처리
for (let i = 0; i < dataRows.length; i++) {
const row = dataRows[i];
@@ -81,35 +94,73 @@ export async function processFileImport( errors.push({
row: rowIndex,
message: errorMessage,
+ originalData: cleanedRow
});
errorCount++;
continue;
}
+
+ // itemCode 분할 처리
+ const rawItemCodes = splitItemCodes(cleanedRow.itemCode);
- // 아이템 생성
- const result = await createShipbuildingImportItem({
- itemCode: cleanedRow.itemCode,
- workType: cleanedRow.workType as "기장" | "전장" | "선실" | "배관" | "철의" | "선체",
- shipTypes: cleanedRow.shipTypes,
- itemList: cleanedRow.itemList,
- });
+ // 빈 itemCode 처리 (임시 코드 생성)
+ const { codes: itemCodes, nextNumber } = processEmptyItemCodes(rawItemCodes, nextIMNumber);
+ nextIMNumber = nextNumber;
+
+ // 각 itemCode에 대해 개별 처리
+ let rowSuccessCount = 0;
+ let rowErrorCount = 0;
- if (result.success || !result.error) {
- successCount++;
- } else {
- errors.push({
- row: rowIndex,
- message: result.message || result.error || "알 수 없는 오류",
- });
- errorCount++;
+ for (const singleItemCode of itemCodes) {
+ try {
+ // 아이템 생성
+ const result = await createShipbuildingImportItem({
+ itemCode: singleItemCode,
+ workType: cleanedRow.workType as "기장" | "전장" | "선실" | "배관" | "철의" | "선체",
+ shipTypes: cleanedRow.shipTypes,
+ itemList: cleanedRow.itemList,
+ });
+
+ if (result.success || !result.error) {
+ rowSuccessCount++;
+ processedItemCodes.push(singleItemCode);
+ } else {
+ rowErrorCount++;
+ if (result.message?.includes('중복') || result.error?.includes('중복')) {
+ duplicateItemCodes.push(singleItemCode);
+ }
+
+ errors.push({
+ row: rowIndex,
+ message: `${singleItemCode}: ${result.message || result.error || "알 수 없는 오류"}`,
+ itemCode: singleItemCode,
+ workType: cleanedRow.workType,
+ originalData: cleanedRow
+ });
+ }
+ } catch (error) {
+ rowErrorCount++;
+ console.error(`${rowIndex}행 ${singleItemCode} 처리 오류:`, error);
+ errors.push({
+ row: rowIndex,
+ message: `${singleItemCode}: ${error instanceof Error ? error.message : "알 수 없는 오류"}`,
+ itemCode: singleItemCode,
+ workType: cleanedRow.workType,
+ originalData: cleanedRow
+ });
+ }
}
+ // 행별 성공/실패 카운트 업데이트
+ successCount += rowSuccessCount;
+ errorCount += rowErrorCount;
+
} catch (error) {
console.error(`${rowIndex}행 처리 오류:`, error);
-
errors.push({
row: rowIndex,
message: error instanceof Error ? error.message : "알 수 없는 오류",
+ originalData: row as Record<string, any>
});
errorCount++;
}
@@ -120,10 +171,17 @@ export async function processFileImport( }
}
+ // 에러가 있으면 Excel 파일 생성
+ if (errors.length > 0) {
+ await createErrorExcelFile(errors, 'ship');
+ }
+
// 처리 결과 반환
return {
successCount,
errorCount,
- errors
+ errors,
+ processedItemCodes,
+ duplicateItemCodes
};
}
\ No newline at end of file diff --git a/lib/items-tech/table/top/import-item-handler.tsx b/lib/items-tech/table/top/import-item-handler.tsx index 0197d826..19a2e29a 100644 --- a/lib/items-tech/table/top/import-item-handler.tsx +++ b/lib/items-tech/table/top/import-item-handler.tsx @@ -1,7 +1,8 @@ "use client"
import { z } from "zod"
-import { createOffshoreTopItem } from "../../service"
+import { createOffshoreTopItem, getMaxOffshoreTopIMCode } from "../../service"
+import { splitItemCodes, createErrorExcelFile, ExtendedProcessResult, processEmptyItemCodes } from "../../utils/import-utils"
// 해양 TOP 기능(공종) 유형 enum
const TOP_WORK_TYPES = ["TM", "TS", "TE", "TP", "TA"] as const;
@@ -16,23 +17,25 @@ const itemSchema = z.object({ subItemList: z.string().nullable().optional(),
});
-interface ProcessResult {
- successCount: number;
- errorCount: number;
- errors: Array<{ row: number; message: string }>;
-}
-
/**
* Excel 파일에서 가져온 해양 TOP 아이템 데이터 처리하는 함수
*/
export async function processTopFileImport(
jsonData: Record<string, unknown>[],
progressCallback?: (current: number, total: number) => void
-): Promise<ProcessResult> {
+): Promise<ExtendedProcessResult> {
// 결과 카운터 초기화
let successCount = 0;
let errorCount = 0;
- const errors: Array<{ row: number; message: string }> = [];
+ const errors: Array<{
+ row: number;
+ message: string;
+ itemCode?: string;
+ workType?: string;
+ originalData?: Record<string, any>;
+ }> = [];
+ const processedItemCodes: string[] = [];
+ const duplicateItemCodes: string[] = [];
// 빈 행 등 필터링
const dataRows = jsonData.filter(row => {
@@ -45,9 +48,19 @@ export async function processTopFileImport( // 데이터 행이 없으면 빈 결과 반환
if (dataRows.length === 0) {
- return { successCount: 0, errorCount: 0, errors: [] };
+ return {
+ successCount: 0,
+ errorCount: 0,
+ errors: [],
+ processedItemCodes: [],
+ duplicateItemCodes: []
+ };
}
+ // 기존 IM 코드의 최대 번호 조회
+ const maxIMCode = await getMaxOffshoreTopIMCode();
+ let nextIMNumber = maxIMCode + 1;
+
// 각 행에 대해 처리
for (let i = 0; i < dataRows.length; i++) {
const row = dataRows[i];
@@ -84,33 +97,73 @@ export async function processTopFileImport( errors.push({
row: rowIndex,
message: errorMessage,
+ originalData: cleanedRow
});
errorCount++;
continue;
}
- // 해양 TOP 아이템 생성
- const result = await createOffshoreTopItem({
- itemCode: cleanedRow.itemCode,
- workType: cleanedRow.workType as "TM" | "TS" | "TE" | "TP" | "TA",
- itemList: cleanedRow.itemList,
- subItemList: cleanedRow.subItemList,
- });
+ // itemCode 분할 처리
+ const rawItemCodes = splitItemCodes(cleanedRow.itemCode);
- if (result.success) {
- successCount++;
- } else {
- errors.push({
- row: rowIndex,
- message: result.message || result.error || "알 수 없는 오류",
- });
- errorCount++;
+ // 빈 itemCode 처리 (임시 코드 생성)
+ const { codes: itemCodes, nextNumber } = processEmptyItemCodes(rawItemCodes, nextIMNumber);
+ nextIMNumber = nextNumber;
+
+ // 각 itemCode에 대해 개별 처리
+ let rowSuccessCount = 0;
+ let rowErrorCount = 0;
+
+ for (const singleItemCode of itemCodes) {
+ try {
+ // 해양 TOP 아이템 생성
+ const result = await createOffshoreTopItem({
+ itemCode: singleItemCode,
+ workType: cleanedRow.workType as "TM" | "TS" | "TE" | "TP" | "TA",
+ itemList: cleanedRow.itemList,
+ subItemList: cleanedRow.subItemList,
+ });
+
+ if (result.success) {
+ rowSuccessCount++;
+ processedItemCodes.push(singleItemCode);
+ } else {
+ rowErrorCount++;
+ if (result.message?.includes('중복') || result.error?.includes('중복')) {
+ duplicateItemCodes.push(singleItemCode);
+ }
+
+ errors.push({
+ row: rowIndex,
+ message: `${singleItemCode}: ${result.message || result.error || "알 수 없는 오류"}`,
+ itemCode: singleItemCode,
+ workType: cleanedRow.workType,
+ originalData: cleanedRow
+ });
+ }
+ } catch (error) {
+ rowErrorCount++;
+ console.error(`${rowIndex}행 ${singleItemCode} 처리 오류:`, error);
+ errors.push({
+ row: rowIndex,
+ message: `${singleItemCode}: ${error instanceof Error ? error.message : "알 수 없는 오류"}`,
+ itemCode: singleItemCode,
+ workType: cleanedRow.workType,
+ originalData: cleanedRow
+ });
+ }
}
+
+ // 행별 성공/실패 카운트 업데이트
+ successCount += rowSuccessCount;
+ errorCount += rowErrorCount;
+
} catch (error) {
console.error(`${rowIndex}행 처리 오류:`, error);
errors.push({
row: rowIndex,
message: error instanceof Error ? error.message : "알 수 없는 오류",
+ originalData: row as Record<string, any>
});
errorCount++;
}
@@ -121,10 +174,17 @@ export async function processTopFileImport( }
}
+ // 에러가 있으면 Excel 파일 생성
+ if (errors.length > 0) {
+ await createErrorExcelFile(errors, 'top');
+ }
+
// 처리 결과 반환
return {
successCount,
errorCount,
- errors
+ errors,
+ processedItemCodes,
+ duplicateItemCodes
};
}
diff --git a/lib/items-tech/utils/import-utils.ts b/lib/items-tech/utils/import-utils.ts new file mode 100644 index 00000000..e8a0d7a5 --- /dev/null +++ b/lib/items-tech/utils/import-utils.ts @@ -0,0 +1,240 @@ +import * as ExcelJS from 'exceljs'; +import { saveAs } from 'file-saver'; + +/** + * itemCode 문자열을 분할하여 배열로 반환 + * 공백이나 콤마로 구분된 여러 itemCode를 처리 + * 빈 itemCode의 경우 빈 문자열 하나를 포함한 배열 반환 + */ +export function splitItemCodes(itemCode: string): string[] { + if (!itemCode || typeof itemCode !== 'string') { + return [""]; // 빈 itemCode의 경우 빈 문자열 하나 반환 + } + + const trimmedCode = itemCode.trim(); + if (trimmedCode === '') { + return [""]; // 공백만 있는 경우도 빈 문자열 하나 반환 + } + + // 공백과 콤마로 분할하고, trim 처리 (빈 문자열도 유지) + return trimmedCode + .split(/[\s,]+/) + .map(code => code.trim()); +} + +/** + * 임시 IM 코드 생성 (4자리 숫자 형식) + */ +export function generateTempIMCode(startNumber: number): string { + return `IM${startNumber.toString().padStart(4, '0')}`; +} + +/** + * itemCode가 비어있거나 유효하지 않을 때 임시 코드 생성 + * @param itemCodes 분할된 itemCode 배열 + * @param startNumber 시작 번호 + * @returns 처리된 itemCode 배열 + */ +export function processEmptyItemCodes(itemCodes: string[], startNumber: number): { codes: string[], nextNumber: number } { + const processedCodes: string[] = []; + let currentNumber = startNumber; + + for (const code of itemCodes) { + if (!code || code.trim() === '') { + // 빈 코드인 경우 임시 코드 생성 + processedCodes.push(generateTempIMCode(currentNumber)); + currentNumber++; + } else { + // 유효한 코드인 경우 그대로 사용 + processedCodes.push(code); + } + } + + return { + codes: processedCodes, + nextNumber: currentNumber + }; +} + +/** + * 에러 정보를 포함한 Excel 파일 생성 및 다운로드 + */ +export async function createErrorExcelFile( + errors: Array<{ + row: number; + message: string; + itemCode?: string; + workType?: string; + originalData?: Record<string, any>; + }>, + itemType: 'top' | 'hull' | 'ship' +): Promise<string> { + try { + const workbook = new ExcelJS.Workbook(); + + // 에러 시트 생성 + const errorSheet = workbook.addWorksheet('Import 오류 목록'); + + // 헤더 설정 + const headers = [ + '행 번호', + '아이템코드', + '기능(공종)', + '자재명', + '자재명(상세)', + '선종', // 조선 아이템의 경우 + '오류 내용', + '해결 방법' + ]; + + errorSheet.addRow(headers); + + // 헤더 스타일 + const headerRow = errorSheet.getRow(1); + headerRow.eachCell((cell) => { + cell.fill = { + type: 'pattern', + pattern: 'solid', + fgColor: { argb: 'FFFF6B6B' } + }; + cell.font = { + bold: true, + color: { argb: 'FFFFFFFF' } + }; + cell.border = { + top: { style: 'thin' }, + left: { style: 'thin' }, + bottom: { style: 'thin' }, + right: { style: 'thin' } + }; + }); + + // 에러 데이터 추가 + errors.forEach((error) => { + const originalData = error.originalData || {}; + const errorRow = errorSheet.addRow([ + error.row, + error.itemCode || originalData.itemCode || '', + error.workType || originalData.workType || '', + originalData.itemList || '', + originalData.subItemList || '', + originalData.shipTypes || '', // 조선 아이템의 경우 + error.message, + getSolutionMessage(error.message) + ]); + + // 에러 행 스타일 + errorRow.eachCell((cell, colNumber) => { + if (colNumber === 7) { // 오류 내용 컬럼 + cell.fill = { + type: 'pattern', + pattern: 'solid', + fgColor: { argb: 'FFFFE0E0' } + }; + } + cell.border = { + top: { style: 'thin' }, + left: { style: 'thin' }, + bottom: { style: 'thin' }, + right: { style: 'thin' } + }; + }); + }); + + // 안내 시트 생성 + const instructionSheet = workbook.addWorksheet('오류 해결 가이드'); + + const instructions = [ + ['📌 오류 해결 방법 안내', ''], + ['', ''], + ['🔍 중복 아이템코드 오류', ''], + ['• 원인: 이미 존재하는 아이템코드입니다.', ''], + ['• 해결: 다른 아이템코드를 사용하거나 기존 아이템을 수정하세요.', ''], + ['', ''], + ['🔍 필수 필드 누락 오류', ''], + ['• 원인: 기능(공종) 등 필수 필드가 비어있습니다.', ''], + ['• 해결: 모든 필수 필드를 입력하세요.', ''], + ['', ''], + ['🔍 데이터 형식 오류', ''], + ['• 원인: 데이터 형식이 올바르지 않습니다.', ''], + ['• 해결: 올바른 형식으로 데이터를 입력하세요.', ''], + ['', ''], + ['📞 추가 문의: 시스템 관리자', ''] + ]; + + instructions.forEach((rowData, index) => { + const row = instructionSheet.addRow(rowData); + if (index === 0) { + row.getCell(1).font = { bold: true, size: 14, color: { argb: 'FF1F4E79' } }; + } else if (rowData[0]?.includes('📌') || rowData[0]?.includes('🔍')) { + row.getCell(1).font = { bold: true, color: { argb: 'FF1F4E79' } }; + } else if (rowData[0]?.includes(':')) { + row.getCell(1).font = { bold: true, color: { argb: 'FF1F4E79' } }; + } + }); + + instructionSheet.getColumn(1).width = 60; + + // 기본 컬럼 너비 설정 + errorSheet.getColumn(1).width = 10; // 행 번호 + errorSheet.getColumn(2).width = 20; // 아이템코드 + errorSheet.getColumn(3).width = 15; // 기능(공종) + errorSheet.getColumn(4).width = 30; // 자재명 + errorSheet.getColumn(5).width = 30; // 자재명(상세) + errorSheet.getColumn(6).width = 20; // 선종 + errorSheet.getColumn(7).width = 60; // 오류 내용 + errorSheet.getColumn(8).width = 40; // 해결 방법 + + // 파일 생성 및 다운로드 + const buffer = await workbook.xlsx.writeBuffer(); + const blob = new Blob([buffer], { + type: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet" + }); + + const itemTypeNames = { + top: '해양TOP', + hull: '해양HULL', + ship: '조선' + }; + + const fileName = `${itemTypeNames[itemType]}_Import_오류_${new Date().toISOString().split('T')[0]}_${Date.now()}.xlsx`; + saveAs(blob, fileName); + + return fileName; + } catch (error) { + console.error("오류 파일 생성 중 오류:", error); + return ''; + } +} + +/** + * 오류 메시지에 따른 해결 방법 반환 + */ +function getSolutionMessage(errorMessage: string): string { + if (errorMessage.includes('중복')) { + return '다른 아이템코드를 사용하거나 기존 아이템을 수정하세요.'; + } else if (errorMessage.includes('필수')) { + return '모든 필수 필드를 입력하세요.'; + } else if (errorMessage.includes('형식')) { + return '올바른 형식으로 데이터를 입력하세요.'; + } else { + return '데이터를 확인하고 다시 시도하세요.'; + } +} + +/** + * 확장된 ProcessResult 인터페이스 + */ +export interface ExtendedProcessResult { + successCount: number; + errorCount: number; + errors: Array<{ + row: number; + message: string; + itemCode?: string; + workType?: string; + originalData?: Record<string, any>; + }>; + processedItemCodes: string[]; // 성공적으로 처리된 아이템코드들 + duplicateItemCodes: string[]; // 중복된 아이템코드들 +} diff --git a/lib/site-visit/client-site-visit-wrapper.tsx b/lib/site-visit/client-site-visit-wrapper.tsx index b92eda3b..c93060b3 100644 --- a/lib/site-visit/client-site-visit-wrapper.tsx +++ b/lib/site-visit/client-site-visit-wrapper.tsx @@ -366,8 +366,20 @@ export function ClientSiteVisitWrapper({ </TableCell>
<TableCell>
{request.result ? (
- <Badge variant={request.result === "APPROVED" ? "default" : "destructive"}>
- {request.result === "APPROVED" ? "통과" : "불가"}
+ <Badge
+ variant={
+ request.result === "APPROVED"
+ ? "default"
+ : request.result === "SUPPLEMENT"
+ ? "secondary"
+ : "destructive"
+ }
+ >
+ {request.result === "APPROVED"
+ ? "통과"
+ : request.result === "SUPPLEMENT"
+ ? "보완"
+ : "불가"}
</Badge>
) : "-"}
</TableCell>
diff --git a/lib/soap/mdg/send/vendor-master/action.ts b/lib/soap/mdg/send/vendor-master/action.ts index a7b4d8a4..8bb2f633 100644 --- a/lib/soap/mdg/send/vendor-master/action.ts +++ b/lib/soap/mdg/send/vendor-master/action.ts @@ -1,7 +1,7 @@ 'use server' import db from "@/db/db"; -import { +import { VENDOR_MASTER_BP_HEADER, VENDOR_MASTER_BP_HEADER_ADDRESS, VENDOR_MASTER_BP_HEADER_ADDRESS_AD_EMAIL, @@ -17,7 +17,7 @@ import { VENDOR_MASTER_BP_HEADER_BP_VENGEN_BP_PORG_ZVPFN } from "@/db/schema/MDG/mdg"; import { eq, sql, desc } from "drizzle-orm"; -import { withSoapLogging } from "@/lib/soap/utils"; +import { withSoapLogging } from "../../../utils"; import { XMLBuilder } from 'fast-xml-parser'; import { CSV_FIELDS } from './csv-fields'; @@ -34,7 +34,7 @@ const MDG_ENDPOINT_URL = "http://shii8dvddb01.hec.serp.shi.samsung.net:50000/sap function generateSAPXICompatibleXML(supplierMaster: Record<string, string>): string { // XML 선언을 별도로 처리 const xmlDeclaration = '<?xml version="1.0" encoding="UTF-8"?>\n'; - + // SOAP Envelope 구조 정의 const soapEnvelope = { 'soap:Envelope': { @@ -62,14 +62,14 @@ function generateSAPXICompatibleXML(supplierMaster: Record<string, string>): str tagValueProcessor: (name, val) => val, // 값 처리기 attributeValueProcessor: (name, val) => val // 속성 처리기 }); - + const xmlBody = builder.build(soapEnvelope); - + // XML 선언과 Body 결합 const completeXML = xmlDeclaration + xmlBody; - + console.log('🔍 생성된 XML (전체):', completeXML); - + return completeXML; } @@ -100,10 +100,10 @@ async function sendXMLToMDG(xmlData: string): Promise<{ } else { console.warn('⚠️ MDG SOAP 인증 정보가 환경변수에 설정되지 않았습니다.'); } - + console.log('📤 MDG 전송 시작'); console.log('🔍 전송 XML (첫 500자):', xmlData.substring(0, 500)); - + const res = await fetch(MDG_ENDPOINT_URL, { method: 'POST', headers, @@ -111,7 +111,7 @@ async function sendXMLToMDG(xmlData: string): Promise<{ }); const text = await res.text(); - + console.log('📥 MDG 응답 수신:', res.status, res.statusText); console.log('🔍 응답 XML (첫 500자):', text.substring(0, 500)); @@ -150,11 +150,11 @@ async function fetchVendorData(vendorCode: string) { .from(VENDOR_MASTER_BP_HEADER) .where(eq(VENDOR_MASTER_BP_HEADER.VNDRCD, vendorCode)) .limit(1); - + if (!vendorHeader) { return null; } - + const [ addresses, adEmails, @@ -182,7 +182,7 @@ async function fetchVendorData(vendorCode: string) { db.select().from(VENDOR_MASTER_BP_HEADER_BP_VENGEN_BP_PORG).where(eq(VENDOR_MASTER_BP_HEADER_BP_VENGEN_BP_PORG.VNDRCD, vendorCode)), db.select().from(VENDOR_MASTER_BP_HEADER_BP_VENGEN_BP_PORG_ZVPFN).where(eq(VENDOR_MASTER_BP_HEADER_BP_VENGEN_BP_PORG_ZVPFN.VNDRCD, vendorCode)) ]); - + return { vendorHeader, addresses, @@ -198,7 +198,7 @@ async function fetchVendorData(vendorCode: string) { bpPorgs, zvpfns }; - + } catch (error) { console.error(`VENDOR ${vendorCode} 데이터 조회 실패:`, error); throw error; @@ -259,15 +259,15 @@ export async function sendVendorMasterToMDG(vendorCodes: string[]): Promise<{ }> { try { console.log(`🚀 VENDOR_MASTER 송신 시작: ${vendorCodes.length}개 벤더`); - + const results: Array<{ vendorCode: string; success: boolean; error?: string }> = []; - + for (const vendorCode of vendorCodes) { try { console.log(`📤 VENDOR ${vendorCode} 데이터 조회 중...`); - + const vendorData = await fetchVendorData(vendorCode); - + if (!vendorData) { results.push({ vendorCode, @@ -276,13 +276,13 @@ export async function sendVendorMasterToMDG(vendorCodes: string[]): Promise<{ }); continue; } - + const supplierMaster = buildSupplierMasterData(vendorData); console.log(`📄 VENDOR ${vendorCode} 데이터 생성 완료`); - + const generatedXML = generateSAPXICompatibleXML(supplierMaster); const result = await sendXMLToMDG(generatedXML); - + if (result.success) { console.log(`✅ VENDOR ${vendorCode} MDG 전송 성공`); results.push({ @@ -297,7 +297,7 @@ export async function sendVendorMasterToMDG(vendorCodes: string[]): Promise<{ error: result.message }); } - + } catch (error) { console.error(`❌ VENDOR ${vendorCode} 전송 실패:`, error); results.push({ @@ -307,18 +307,18 @@ export async function sendVendorMasterToMDG(vendorCodes: string[]): Promise<{ }); } } - + const successCount = results.filter(r => r.success).length; const failCount = results.length - successCount; - + console.log(`🎉 VENDOR_MASTER 송신 완료: 성공 ${successCount}개, 실패 ${failCount}개`); - + return { success: failCount === 0, message: `전송 완료: 성공 ${successCount}개, 실패 ${failCount}개`, results }; - + } catch (error) { console.error('❌ VENDOR_MASTER 송신 중 전체 오류 발생:', error); return { @@ -328,6 +328,39 @@ export async function sendVendorMasterToMDG(vendorCodes: string[]): Promise<{ } } +// 필수 필드 검증 함수 +function validateMandatoryFields(formData: Record<string, string>): { + isValid: boolean; + missingFields: string[]; + errorMessage: string; +} { + const missingFields: string[] = []; + + // CSV_FIELDS에서 mandatory가 true인 필드들만 필수 필드로 체크 + CSV_FIELDS.forEach(field => { + if (field.mandatory) { + const value = formData[field.field]; + if (!value || value.trim() === '') { + missingFields.push(field.field); + } + } + }); + + if (missingFields.length > 0) { + return { + isValid: false, + missingFields, + errorMessage: `필수 필드가 누락되었습니다: ${missingFields.join(', ')}` + }; + } + + return { + isValid: true, + missingFields: [], + errorMessage: '' + }; +} + // 테스트용 폼 데이터 송신 함수 export async function sendTestVendorDataToMDG(formData: Record<string, string>): Promise<{ success: boolean; @@ -337,7 +370,19 @@ export async function sendTestVendorDataToMDG(formData: Record<string, string>): }> { try { console.log('🚀 테스트용 VENDOR 데이터 송신 시작'); - + + // 필수 필드 검증 + const validation = validateMandatoryFields(formData); + if (!validation.isValid) { + console.error('❌ 필수 필드 누락:', validation.missingFields); + return { + success: false, + message: validation.errorMessage, + responseData: undefined, + generatedXML: undefined + }; + } + const seen = new Set<string>(); const uniqueFields = CSV_FIELDS.filter(f => { if (seen.has(f.field)) return false; @@ -351,18 +396,18 @@ export async function sendTestVendorDataToMDG(formData: Record<string, string>): }); const generatedXML = generateSAPXICompatibleXML(supplierMaster); - + console.log('📄 SAP XI 호환 XML 생성 완료'); - + const result = await sendXMLToMDG(generatedXML); - + return { success: result.success, message: result.success ? '테스트 송신이 완료되었습니다.' : result.message, responseData: result.responseText, generatedXML }; - + } catch (error) { console.error('❌ 테스트 송신 실패:', error); return { @@ -387,44 +432,44 @@ export async function sendAllVendorsToMDG() { const vendors = await db .select({ VNDRCD: VENDOR_MASTER_BP_HEADER.VNDRCD }) .from(VENDOR_MASTER_BP_HEADER); - + const vendorCodes = vendors.map(v => v.VNDRCD); - + if (vendorCodes.length === 0) { return { success: false, message: '송신할 VENDOR 데이터가 없습니다.' }; } - + console.log(`⚠️ 전체 VENDOR 송신 요청: ${vendorCodes.length}개`); - + const batchSize = 10; const results: Array<{ vendorCode: string; success: boolean; error?: string }> = []; - + for (let i = 0; i < vendorCodes.length; i += batchSize) { const batch = vendorCodes.slice(i, i + batchSize); console.log(`📦 배치 ${Math.floor(i / batchSize) + 1} 처리 중... (${batch.length}개)`); - + const batchResult = await sendVendorMasterToMDG(batch); if (batchResult.results) { results.push(...batchResult.results); } - + if (i + batchSize < vendorCodes.length) { await new Promise(resolve => setTimeout(resolve, 1000)); } } - + const successCount = results.filter(r => r.success).length; const failCount = results.length - successCount; - + return { success: failCount === 0, message: `전체 송신 완료: 성공 ${successCount}개, 실패 ${failCount}개`, results }; - + } catch (error) { console.error('전체 VENDOR 송신 중 오류:', error); return { @@ -442,9 +487,9 @@ export async function sendModifiedVendorsToMDG(): Promise<{ }> { try { console.log('🔍 수정된 VENDOR 데이터 조회 중...'); - + const modifiedVendors = await db - .select({ + .select({ VNDRCD: VENDOR_MASTER_BP_HEADER.VNDRCD, createdAt: VENDOR_MASTER_BP_HEADER.createdAt, updatedAt: VENDOR_MASTER_BP_HEADER.updatedAt @@ -453,9 +498,9 @@ export async function sendModifiedVendorsToMDG(): Promise<{ .where( sql`EXTRACT(EPOCH FROM ${VENDOR_MASTER_BP_HEADER.updatedAt}) - EXTRACT(EPOCH FROM ${VENDOR_MASTER_BP_HEADER.createdAt}) > 1` ); - + const vendorCodes = modifiedVendors.map(v => v.VNDRCD); - + if (vendorCodes.length === 0) { console.log('📝 수정된 VENDOR 데이터가 없습니다.'); return { @@ -463,37 +508,37 @@ export async function sendModifiedVendorsToMDG(): Promise<{ message: '수정된 VENDOR 데이터가 없습니다.' }; } - + console.log(`📋 수정된 VENDOR ${vendorCodes.length}개 발견:`, vendorCodes); - + const batchSize = 10; const results: Array<{ vendorCode: string; success: boolean; error?: string }> = []; - + for (let i = 0; i < vendorCodes.length; i += batchSize) { const batch = vendorCodes.slice(i, i + batchSize); console.log(`📦 수정 데이터 배치 ${Math.floor(i / batchSize) + 1} 처리 중... (${batch.length}개)`); - + const batchResult = await sendVendorMasterToMDG(batch); if (batchResult.results) { results.push(...batchResult.results); } - + if (i + batchSize < vendorCodes.length) { await new Promise(resolve => setTimeout(resolve, 1000)); } } - + const successCount = results.filter(r => r.success).length; const failCount = results.length - successCount; - + console.log(`🎯 수정된 VENDOR 송신 완료: 성공 ${successCount}개, 실패 ${failCount}개`); - + return { success: failCount === 0, message: `수정된 VENDOR 송신 완료: 성공 ${successCount}개, 실패 ${failCount}개`, results }; - + } catch (error) { console.error('❌ 수정된 VENDOR 송신 중 오류:', error); return { @@ -505,7 +550,7 @@ export async function sendModifiedVendorsToMDG(): Promise<{ // 테스트용 N건 송신 export async function sendNVendorsToMDG( - count: number, + count: number, startFrom: number = 0 ): Promise<{ success: boolean; @@ -519,35 +564,35 @@ export async function sendNVendorsToMDG( message: '송신할 건수는 1 이상이어야 합니다.' }; } - + console.log(`🧪 테스트용 VENDOR 송신: ${count}건 (${startFrom}번째부터)`); - + const vendors = await db .select({ VNDRCD: VENDOR_MASTER_BP_HEADER.VNDRCD }) .from(VENDOR_MASTER_BP_HEADER) .limit(count) .offset(startFrom); - + const vendorCodes = vendors.map(v => v.VNDRCD); - + if (vendorCodes.length === 0) { return { success: false, message: `${startFrom}번째부터 ${count}건의 VENDOR 데이터가 없습니다.` }; } - + console.log(`📋 테스트 대상 VENDOR ${vendorCodes.length}개:`, vendorCodes); - + const result = await sendVendorMasterToMDG(vendorCodes); - + console.log(`🧪 테스트 송신 완료: ${vendorCodes.length}개 처리`); - + return { ...result, message: `테스트 송신 완료 (${vendorCodes.length}개): ${result.message}` }; - + } catch (error) { console.error('❌ 테스트 송신 중 오류:', error); return { @@ -567,9 +612,9 @@ export async function sendRecentModifiedVendorsToMDG( }> { try { console.log(`🕒 최근 수정된 VENDOR ${count}건 조회 중...`); - + const recentVendors = await db - .select({ + .select({ VNDRCD: VENDOR_MASTER_BP_HEADER.VNDRCD, updatedAt: VENDOR_MASTER_BP_HEADER.updatedAt }) @@ -579,28 +624,28 @@ export async function sendRecentModifiedVendorsToMDG( ) .orderBy(desc(VENDOR_MASTER_BP_HEADER.updatedAt)) .limit(count); - + const vendorCodes = recentVendors.map(v => v.VNDRCD); - + if (vendorCodes.length === 0) { return { success: true, message: '최근 수정된 VENDOR 데이터가 없습니다.' }; } - - console.log(`📋 최근 수정된 VENDOR ${vendorCodes.length}개:`, + + console.log(`📋 최근 수정된 VENDOR ${vendorCodes.length}개:`, recentVendors.map(v => `${v.VNDRCD}(${v.updatedAt?.toISOString()})`)); - + const result = await sendVendorMasterToMDG(vendorCodes); - + console.log(`🕒 최근 수정 데이터 송신 완료`); - + return { ...result, message: `최근 수정된 ${vendorCodes.length}개 송신 완료: ${result.message}` }; - + } catch (error) { console.error('❌ 최근 수정 데이터 송신 중 오류:', error); return { @@ -621,14 +666,14 @@ export async function getVendorSendStatistics(): Promise<{ const [totalResult] = await db .select({ count: sql<number>`count(*)` }) .from(VENDOR_MASTER_BP_HEADER); - + const [modifiedResult] = await db .select({ count: sql<number>`count(*)` }) .from(VENDOR_MASTER_BP_HEADER) .where( sql`EXTRACT(EPOCH FROM ${VENDOR_MASTER_BP_HEADER.updatedAt}) - EXTRACT(EPOCH FROM ${VENDOR_MASTER_BP_HEADER.createdAt}) > 1` ); - + const [lastModifiedResult] = await db .select({ updatedAt: VENDOR_MASTER_BP_HEADER.updatedAt }) .from(VENDOR_MASTER_BP_HEADER) @@ -637,7 +682,7 @@ export async function getVendorSendStatistics(): Promise<{ ) .orderBy(desc(VENDOR_MASTER_BP_HEADER.updatedAt)) .limit(1); - + const [oldestUnmodifiedResult] = await db .select({ createdAt: VENDOR_MASTER_BP_HEADER.createdAt }) .from(VENDOR_MASTER_BP_HEADER) @@ -646,14 +691,14 @@ export async function getVendorSendStatistics(): Promise<{ ) .orderBy(VENDOR_MASTER_BP_HEADER.createdAt) .limit(1); - + return { total: totalResult.count, modified: modifiedResult.count, lastModified: lastModifiedResult?.updatedAt || undefined, oldestUnmodified: oldestUnmodifiedResult?.createdAt || undefined }; - + } catch (error) { console.error('통계 조회 실패:', error); throw error; diff --git a/lib/utils.ts b/lib/utils.ts index 4d987902..dab65b37 100644 --- a/lib/utils.ts +++ b/lib/utils.ts @@ -288,4 +288,5 @@ export function compareItemNumber(a?: string, b?: string) { if (av !== bv) return av - bv; } return as.length - bs.length; -}
\ No newline at end of file +} + diff --git a/lib/vendor-investigation/table/investigation-table-columns.tsx b/lib/vendor-investigation/table/investigation-table-columns.tsx index 521befa9..a6c1574d 100644 --- a/lib/vendor-investigation/table/investigation-table-columns.tsx +++ b/lib/vendor-investigation/table/investigation-table-columns.tsx @@ -26,6 +26,22 @@ interface GetVendorInvestigationsColumnsProps { openVendorDetailsModal?: (vendorId: number) => void } +// Helper function for investigation method variants +function getMethodVariant(method: string): "default" | "secondary" | "outline" | "destructive" { + switch (method) { + case "PURCHASE_SELF_EVAL": + return "secondary" + case "DOCUMENT_EVAL": + return "outline" + case "PRODUCT_INSPECTION": + return "default" + case "SITE_VISIT_EVAL": + return "destructive" + default: + return "outline" + } +} + export function getColumns({ setRowAction, openVendorDetailsModal, @@ -168,6 +184,17 @@ export function getColumns({ ) } + // Handle investigation method + if (column.id === "investigationMethod") { + if (!value) return "" + + return ( + <Badge variant={getMethodVariant(value as string)}> + {formatEnumValue(value as string)} + </Badge> + ) + } + // Handle evaluation result if (column.id === "evaluationResult") { if (!value) return "" @@ -340,6 +367,8 @@ function getStatusVariant(status: string): "default" | "secondary" | "outline" | } } + + function getResultVariant(result: string): "default" | "secondary" | "outline" | "destructive" { switch (result) { case "APPROVED": diff --git a/lib/vendor-regular-registrations/repository.ts b/lib/vendor-regular-registrations/repository.ts index aec3d275..6f73b98f 100644 --- a/lib/vendor-regular-registrations/repository.ts +++ b/lib/vendor-regular-registrations/repository.ts @@ -125,19 +125,6 @@ export async function getVendorRegularRegistrations( return acc;
}, [] as typeof filteredContracts);
- // 디버깅을 위한 로그
- console.log(`📋 벤더 ID ${registration.vendorId} (${registration.companyName}) 현황:`, {
- vendorFiles: vendorFiles.map(f => ({ type: f.attachmentType, fileName: f.fileName })),
- investigationFiles: investigationFiles.map(f => ({ type: f.attachmentType, fileName: f.fileName })),
- allContracts: allVendorContracts.length,
- uniqueContracts: vendorContracts.map(c => ({
- templateName: c.templateName,
- status: c.status,
- createdAt: c.createdAt?.toISOString()
- })),
- contactTypes: vendorContacts.map(c => c.contactType)
- });
-
// 문서 제출 현황 - 국가별 요구사항 적용
const isForeign = registration.country !== 'KR';
const documentSubmissionsStatus = {
@@ -155,47 +142,6 @@ export async function getVendorRegularRegistrations( auditResult: investigationFiles,
};
- // 디버깅용 로그 추가
- console.log(`🔍 벤더 ID ${registration.vendorId} documentFiles 구조:`, {
- businessRegistration: documentFiles.businessRegistration.map(f => ({
- fileName: f.fileName,
- filePath: f.filePath,
- attachmentType: f.attachmentType,
- allKeys: Object.keys(f)
- })),
- creditEvaluation: documentFiles.creditEvaluation.map(f => ({
- fileName: f.fileName,
- filePath: f.filePath,
- attachmentType: f.attachmentType,
- allKeys: Object.keys(f)
- })),
- bankCopy: documentFiles.bankCopy.map(f => ({
- fileName: f.fileName,
- filePath: f.filePath,
- attachmentType: f.attachmentType,
- allKeys: Object.keys(f)
- })),
- auditResult: documentFiles.auditResult.map(f => ({
- fileName: f.fileName,
- attachmentType: f.attachmentType,
- allKeys: Object.keys(f)
- })),
- totalVendorFiles: vendorFiles.length,
- totalInvestigationFiles: investigationFiles.length
- });
-
- // 문서 제출 현황 로그
- console.log(`📊 벤더 ID ${registration.vendorId} 문서 제출 현황:`, {
- documentSubmissionsStatus,
- isForeign,
- vendorFiles: vendorFiles.map(f => ({
- type: f.attachmentType,
- fileName: f.fileName
- })),
- investigationFilesCount: investigationFiles.length,
- country: registration.country
- });
-
// 계약 동의 현황 - 실제 기본 계약 데이터 기반으로 단순화
const contractAgreementsStatus = {
cp: vendorContracts.some(c => c.status === "COMPLETED") ? "completed" : "not_submitted",
@@ -214,16 +160,6 @@ export async function getVendorRegularRegistrations( const additionalInfoTableCompleted = vendorAdditionalInfoData.length > 0;
const additionalInfoCompleted = contactsCompleted && additionalInfoTableCompleted;
- // 추가정보 디버깅 로그
- console.log(`🔍 벤더 ID ${registration.vendorId} 추가정보 상세:`, {
- requiredContactTypes,
- vendorContactTypes: vendorContacts.map(c => c.contactType),
- contactsCompleted,
- additionalInfoTableCompleted,
- additionalInfoData: vendorAdditionalInfoData,
- finalAdditionalInfoCompleted: additionalInfoCompleted
- });
-
// 모든 조건 충족 여부 확인
const allDocumentsSubmitted = Object.values(documentSubmissionsStatus).every(status => status === true);
const allContractsCompleted = vendorContracts.length > 0 && vendorContracts.every(c => c.status === "COMPLETED");
@@ -233,7 +169,8 @@ export async function getVendorRegularRegistrations( const shouldUpdateStatus = allDocumentsSubmitted && allContractsCompleted && safetyQualificationCompleted && additionalInfoCompleted;
// 현재 상태가 조건충족이 아닌데 모든 조건이 충족되면 상태 업데이트
- if (shouldUpdateStatus && registration.status !== "approval_ready") {
+ // 단, 이미 registration_requested 상태라면 자동 업데이트하지 않음
+ if (shouldUpdateStatus && registration.status !== "approval_ready" && registration.status !== "registration_requested") {
// 비동기 업데이트 (백그라운드에서 실행)
updateVendorRegularRegistration(registration.id, {
status: "approval_ready"
@@ -245,7 +182,7 @@ export async function getVendorRegularRegistrations( return {
id: registration.id,
vendorId: registration.vendorId,
- status: shouldUpdateStatus ? "approval_ready" : (registration.status || "audit_pass"),
+ status: registration.status || "audit_pass",
potentialCode: registration.potentialCode,
businessNumber: registration.businessNumber || "",
companyName: registration.companyName || "",
diff --git a/lib/vendor-regular-registrations/service.ts b/lib/vendor-regular-registrations/service.ts index 7ec433b4..c4f1a2a8 100644 --- a/lib/vendor-regular-registrations/service.ts +++ b/lib/vendor-regular-registrations/service.ts @@ -26,6 +26,7 @@ import { } from "@/db/schema";
import db from "@/db/db";
import { inArray, eq, desc, and, lt } from "drizzle-orm";
+import { sendTestVendorDataToMDG } from "@/lib/soap/mdg/send/vendor-master/action";
// 3개월 이상 정규등록검토 상태인 등록을 장기미등록으로 변경
async function updatePendingApprovals() {
@@ -125,7 +126,7 @@ export async function fetchVendorRegularRegistrations(input?: { },
[JSON.stringify(input || {})],
{
- revalidate: 300, // 5분 캐시
+ revalidate: 60, // 1분 캐시로 단축
tags: ["vendor-regular-registrations"],
}
)();
@@ -1184,6 +1185,13 @@ export async function submitRegistrationRequest( }
// 조건충족 상태인지 확인
+ console.log("📋 업데이트 전 현재 데이터:", {
+ registrationId,
+ currentStatus: registration[0].status,
+ currentRemarks: registration[0].remarks,
+ currentUpdatedAt: registration[0].updatedAt
+ });
+
if (registration[0].status !== "approval_ready") {
return { success: false, error: "조건충족 상태가 아닙니다." };
}
@@ -1197,18 +1205,35 @@ export async function submitRegistrationRequest( status: "requested" // 요청됨
};
- // 상태를 '등록요청됨'으로 변경하고 요청 데이터 저장
- await db
- .update(vendorRegularRegistrations)
- .set({
- status: "registration_requested",
- remarks: `정규업체 등록 요청됨 - ${new Date().toISOString()}\n요청자: ${session.user.name}`,
- updatedAt: new Date(),
- })
- .where(eq(vendorRegularRegistrations.id, registrationId));
+ // 트랜잭션으로 상태 변경
+ const updateResult = await db.transaction(async (tx) => {
+ return await tx
+ .update(vendorRegularRegistrations)
+ .set({
+ status: "registration_requested",
+ remarks: `정규업체 등록 요청됨 - ${new Date().toISOString()}\n요청자: ${session.user.name}`,
+ updatedAt: new Date(),
+ })
+ .where(eq(vendorRegularRegistrations.id, registrationId));
+ });
+
+ console.log("🔄 업데이트 결과:", {
+ registrationId,
+ updateResult,
+ statusToSet: "registration_requested"
+ });
+
- // TODO: MDG 인터페이스 연동
- // await sendToMDG(registrationRequestData);
+
+ // MDG 인터페이스 연동
+ const mdgResult = await sendRegistrationRequestToMDG(registrationId, requestData);
+
+ if (!mdgResult.success) {
+ console.error('❌ MDG 송신 실패:', mdgResult.error);
+ // MDG 송신 실패해도 등록 요청은 성공으로 처리 (재시도 가능하도록)
+ } else {
+ console.log('✅ MDG 송신 성공:', mdgResult.message);
+ }
// TODO: Knox 결재 연동
// - 사업자등록증, 신용평가보고서, 개인정보동의서, 통장사본
@@ -1225,13 +1250,14 @@ export async function submitRegistrationRequest( requestDate: new Date().toISOString()
});
- // 캐시 무효화
+ // 캐시 무효화 - 더 강력한 무효화
revalidateTag("vendor-regular-registrations");
revalidateTag(`vendor-regular-registration-${registrationId}`);
+ revalidateTag("vendor-registration-status");
return {
success: true,
- message: "정규업체 등록 요청이 성공적으로 제출되었습니다.\nKnox 결재 시스템과 MDG 인터페이스 연동은 추후 구현 예정입니다."
+ message: `정규업체 등록 요청이 성공적으로 제출되었습니다.\n${mdgResult.success ? 'MDG 인터페이스 연동이 완료되었습니다.' : 'MDG 인터페이스 연동에 실패했습니다. (재시도 가능)'}\nKnox 결재 시스템 연동은 추후 구현 예정입니다.`
};
} catch (error) {
@@ -1241,4 +1267,165 @@ export async function submitRegistrationRequest( error: error instanceof Error ? error.message : "정규업체 등록 요청 중 오류가 발생했습니다."
};
}
+}
+
+// MDG로 정규업체 등록 요청 데이터를 보내는 함수
+export async function sendRegistrationRequestToMDG(
+ registrationId: number,
+ requestData: RegistrationRequestData
+) {
+ try {
+ console.log('🚀 MDG로 정규업체 등록 요청 데이터 송신 시작');
+
+ // 세션 사용자 정보 가져오기
+ const session = await getServerSession(authOptions);
+ const userId = session?.user?.id || 'EVCP_USER';
+ const userName = session?.user?.name || 'EVCP_USER';
+ // 등록 정보 조회
+ const registration = await db
+ .select()
+ .from(vendorRegularRegistrations)
+ .where(eq(vendorRegularRegistrations.id, registrationId))
+ .limit(1);
+
+ if (!registration[0]) {
+ return { success: false, error: "등록 정보를 찾을 수 없습니다." };
+ }
+
+ // registration[0].vendorId를 이용해 벤더 정보 조회
+ const vendor = await db
+ .select()
+ .from(vendors)
+ .where(eq(vendors.id, registration[0].vendorId))
+ .limit(1);
+
+ if (!vendor[0]) {
+ return { success: false, error: "벤더 정보를 찾을 수 없습니다." };
+ }
+
+ // MDG 필수 필드 매핑 (이메일 내용 기반)
+ const mdgData: Record<string, string> = {
+ // 1. BP_HEADER: 벤더코드가 있으면 벤더코드를, 없으면 eVCP에서 관리번호를 보내드리겠습니다. (필수)
+ BP_HEADER: vendor[0].vendorCode || vendor[0].id.toString(),
+
+ // 2. ZZSRMCD: eVCP에서 내부관리번호를 보내드리겠습니다. (필수)
+ ZZSRMCD: vendor[0].id.toString(),
+
+ // 3. SORT1: 벤더명 보내드립니다. (필수)
+ SORT1: requestData.companyNameKor,
+
+ // 4. NAME1: 벤더명 보내드립니다. (필수)
+ NAME1: requestData.companyNameKor,
+
+ // 5. NAME2: 벤더 영문명 (있는 경우) (선택)
+ NAME2: requestData.companyNameEng || '',
+
+ // 6. KTOKK: 셈플로 받은자료에는 "LIEF"로 되어 있습니다. 고정값 (필수)
+ KTOKK: 'LIEF',
+
+ // 7. J_1KFREPRE: 대표자명 (필수)
+ J_1KFREPRE: requestData.representativeNameKor,
+
+ // 8. MASTERFLAG: 지시자 같은데, 어떤값을 보내면 되나요? -> V (필수)
+ MASTERFLAG: 'V',
+
+ // 9. IBND_TYPE: 입력가능한 값을 알려주시기 바랍니다. -> 생성 : I, 변경: U (필수)
+ IBND_TYPE: 'I',
+
+ // 10. ZZREQID: SAP의 USER ID를 보내드리겠습니다. (필수)
+ ZZREQID: userName,
+
+ // 11. ADDRNO: I/F정의서에는 필수입력으로 되어 있습니다. -> 빈값으로 처리 (필수)
+ ADDRNO: '',
+
+ // 12. COUNTRY: ISO 3166-1의 규약에 따른 국가코드를 보내드릴 예정입니다. (필수)
+ COUNTRY: vendor[0].country || 'KR',
+
+ // 13. POST_CODE1: 우편번호를 송부하겠습니다. (필수)
+ POST_CODE1: vendor[0].postalCode || '',
+
+ // 14. CITY1: 상세 주소를 송부하겠습니다. (필수) - 샘플에서는 상세주소가 들어감
+ CITY1: vendor[0].addressDetail || '',
+
+ // 15. STREET: 주소를 송부하겠습니다. (필수) - 샘플에서는 기본주소가 들어감
+ STREET: vendor[0].address || '',
+
+ // 16. TEL_NUMBER: 전화번호 (필수)
+ TEL_NUMBER: vendor[0].phone || '',
+
+ // 17. R3_USER: 전화/휴대폰 구분자로 해석됩니다. 0이면 전화, 1이면 휴대폰 (필수)
+ R3_USER: '0', // 일반 전화번호로 가정
+
+ // 18. TAXTYPE: 국가 코드에 맞게 하면 됩니다. 국가 KR -> TAXTYPE KR2 (필수)
+ TAXTYPE: (vendor[0].country || 'KR') + '2',
+
+ // 19. TAXNUM: 사업자번호 (필수)
+ TAXNUM: vendor[0].taxId || '',
+
+ // 20. BP_TX_TYP: 대표자 주민번호 YYMMDD + 0000000 (YYMMDD0000000) - 대표자 생년월일 기준으로 생성
+ BP_TX_TYP: requestData.representativeBirthDate ?
+ requestData.representativeBirthDate.replace(/-/g, '') + '0000000' : '',
+
+ // 21. STCD3: 법인등록번호 (선택)
+ STCD3: requestData.corporateNumber || '',
+
+ // 22. CONSNUMBER: 순번 (샘플에서는 1, 2로 설정됨)
+ CONSNUMBER: '1',
+
+ // 23. ZZIND03: 기업규모 (A,B,C,D 값을 넣는 것으로 알고 있습니다.) (선택)
+ ZZIND03: 'B', // 기본값으로 B 설정
+
+ // 24. J_1KFTBUS: 사업유형 (샘플에서는 "건설업외")
+ J_1KFTBUS: '',
+
+ // 25. J_1KFTIND: 산업유형 (샘플에서는 "제조업")
+ J_1KFTIND: '',
+
+ // 26. SMTP_ADDR: 대표 이메일 주소 (필수)
+ SMTP_ADDR: requestData.representativeEmail || vendor[0].email || '',
+
+ // 27. URI_ADDR: 웹사이트 주소 (선택)
+ URI_ADDR: vendor[0].website || '',
+
+ // 28. ZZCNAME1: 해당 벤더의 첫번째 유저의 이름 (영문) (선택)
+ ZZCNAME1: requestData.representativeNameEng || '',
+
+ // 29. ZZCNAME2: 해당 벤더의 첫번째 유저의 이름 (한글) (선택)
+ ZZCNAME2: requestData.representativeNameKor || '',
+
+ // 30. ZZTELF1_C: 해당 벤더의 첫번째 유저의 전화번호 (선택)
+ ZZTELF1_C: requestData.representativeContact || '',
+ };
+
+ // MDG로 데이터 전송
+ const result = await sendTestVendorDataToMDG(mdgData);
+
+ console.log('📤 MDG 송신 결과:', result);
+
+ if (!result.success) {
+ // 필수 필드 누락 에러인 경우 더 자세한 메시지 제공
+ if (result.message.includes('필수 필드가 누락되었습니다')) {
+ return {
+ success: false,
+ error: `MDG 송신 실패: ${result.message}\n\n누락된 필수 필드들을 확인하고 다시 시도해주세요.`
+ };
+ }
+ }
+
+ return {
+ success: result.success,
+ message: result.success ?
+ 'MDG로 정규업체 등록 요청이 성공적으로 전송되었습니다.' :
+ `MDG 송신 실패: ${result.message}`,
+ responseData: result.responseData,
+ generatedXML: result.generatedXML
+ };
+
+ } catch (error) {
+ console.error('❌ MDG 송신 실패:', error);
+ return {
+ success: false,
+ error: error instanceof Error ? error.message : 'MDG 송신 중 오류가 발생했습니다.'
+ };
+ }
}
\ No newline at end of file diff --git a/lib/vendor-regular-registrations/table/vendor-regular-registrations-table-toolbar-actions.tsx b/lib/vendor-regular-registrations/table/vendor-regular-registrations-table-toolbar-actions.tsx index 3a1216f2..df2ab53a 100644 --- a/lib/vendor-regular-registrations/table/vendor-regular-registrations-table-toolbar-actions.tsx +++ b/lib/vendor-regular-registrations/table/vendor-regular-registrations-table-toolbar-actions.tsx @@ -2,7 +2,6 @@ import { type Table } from "@tanstack/react-table"
import { toast } from "sonner"
-import { useRouter } from "next/navigation"
import { Button } from "@/components/ui/button"
import { Mail, FileWarning, Scale, FileText } from "lucide-react"
@@ -16,6 +15,7 @@ import { import { useState } from "react"
import { SkipReasonDialog } from "@/components/vendor-regular-registrations/skip-reason-dialog"
import { RegistrationRequestDialog } from "@/components/vendor-regular-registrations/registration-request-dialog"
+import { useRouter } from "next/navigation"
interface VendorRegularRegistrationsTableToolbarActionsProps {
table: Table<VendorRegularRegistration>
@@ -158,7 +158,7 @@ export function VendorRegularRegistrationsTableToolbarActions({ if (result.success) {
toast.success(result.message);
setRegistrationRequestDialog({ open: false, registration: null });
- window.location.reload(); // 데이터 새로고침
+ router.refresh();
} else {
toast.error(result.error);
}
@@ -178,26 +178,6 @@ export function VendorRegularRegistrationsTableToolbarActions({ return (
<div className="flex items-center gap-2">
- {/* <Button
- variant="outline"
- size="sm"
- onClick={handleSyncDocuments}
- disabled={syncLoading.documents || selectedRows.length === 0}
- >
- <FileText className="mr-2 h-4 w-4" />
- {syncLoading.documents ? "동기화 중..." : "문서 동기화"}
- </Button>
-
- <Button
- variant="outline"
- size="sm"
- onClick={handleSyncAgreements}
- disabled={syncLoading.agreements || selectedRows.length === 0}
- >
- <RefreshCw className="mr-2 h-4 w-4" />
- {syncLoading.agreements ? "동기화 중..." : "계약 동기화"}
- </Button> */}
-
<Button
variant="outline"
size="sm"
diff --git a/lib/vendors/service.ts b/lib/vendors/service.ts index 4cca3b12..e3a38891 100644 --- a/lib/vendors/service.ts +++ b/lib/vendors/service.ts @@ -1576,7 +1576,7 @@ export async function approveVendors(input: ApproveVendorsInput & { userId: numb // 기존 사용자-역할 관계 확인 const existingUserRole = await tx - .select({ id: userRoles.id }) + .select({ userId: userRoles.userId }) .from(userRoles) .where( and( @@ -1621,7 +1621,10 @@ export async function approveVendors(input: ApproveVendorsInput & { userId: numb try { // 사용자 언어 확인 const userInfo = await tx - .select({ language: users.language }) + .select({ + id: users.id, + language: users.language + }) .from(users) .where(eq(users.email, vendor.email)) .limit(1); diff --git a/lib/vendors/table/approve-vendor-dialog.tsx b/lib/vendors/table/approve-vendor-dialog.tsx index 9c175dc5..940710f5 100644 --- a/lib/vendors/table/approve-vendor-dialog.tsx +++ b/lib/vendors/table/approve-vendor-dialog.tsx @@ -55,20 +55,29 @@ export function ApproveVendorsDialog({ } startApproveTransition(async () => { - const { error } = await approveVendors({ - ids: vendors.map((vendor) => vendor.id), - userId: Number(session.user.id) + try { + console.log("🔍 [DEBUG] 승인 요청 시작 - vendors:", vendors.map(v => ({ id: v.id, vendorName: v.vendorName, email: v.email }))); + console.log("🔍 [DEBUG] 세션 정보:", { userId: session.user.id, userType: typeof session.user.id }); + + const { error } = await approveVendors({ + ids: vendors.map((vendor) => vendor.id), + userId: Number(session.user.id) + }) - }) + if (error) { + console.error("🚨 [DEBUG] 승인 처리 에러:", error); + toast.error(error) + return + } - if (error) { - toast.error(error) - return + console.log("✅ [DEBUG] 승인 처리 성공"); + props.onOpenChange?.(false) + toast.success("Vendors successfully approved for review") + onSuccess?.() + } catch (error) { + console.error("🚨 [DEBUG] 예상치 못한 에러:", error); + toast.error("예상치 못한 오류가 발생했습니다.") } - - props.onOpenChange?.(false) - toast.success("Vendors successfully approved for review") - onSuccess?.() }) } diff --git a/lib/vendors/table/request-pq-dialog.tsx b/lib/vendors/table/request-pq-dialog.tsx index 20388f71..14a1cd01 100644 --- a/lib/vendors/table/request-pq-dialog.tsx +++ b/lib/vendors/table/request-pq-dialog.tsx @@ -37,6 +37,7 @@ import { Checkbox } from "@/components/ui/checkbox" import { Label } from "@/components/ui/label"
import { Input } from "@/components/ui/input"
import { Badge } from "@/components/ui/badge"
+import { Progress } from "@/components/ui/progress"
import { Vendor } from "@/db/schema/vendors"
import { requestBasicContractInfo, requestPQVendors } from "../service"
import { getProjectsWithPQList } from "@/lib/pq/service"
@@ -98,6 +99,11 @@ export function RequestPQDialog({ vendors, showTrigger = true, onSuccess, ...pro // 비밀유지 계약서 첨부파일 관련 상태
const [ndaAttachments, setNdaAttachments] = React.useState<File[]>([])
const [isUploadingNdaFiles, setIsUploadingNdaFiles] = React.useState(false)
+
+ // 프로그레스 관련 상태
+ const [progressValue, setProgressValue] = React.useState(0)
+ const [currentStep, setCurrentStep] = React.useState("")
+ const [showProgress, setShowProgress] = React.useState(false)
// 아이템 검색 필터링
React.useEffect(() => {
@@ -180,6 +186,9 @@ export function RequestPQDialog({ vendors, showTrigger = true, onSuccess, ...pro setShowItemDropdown(false)
setNdaAttachments([])
setIsUploadingNdaFiles(false)
+ setProgressValue(0)
+ setCurrentStep("")
+ setShowProgress(false)
}
}, [props.open])
@@ -235,9 +244,22 @@ export function RequestPQDialog({ vendors, showTrigger = true, onSuccess, ...pro if (!dueDate) return toast.error("마감일을 선택하세요.")
if (!session?.user?.id) return toast.error("인증 실패")
+ // 프로그레스 바를 즉시 표시
+ setShowProgress(true)
+ setProgressValue(0)
+ setCurrentStep("시작 중...")
+
startApproveTransition(async () => {
try {
+
+ // 전체 단계 수 계산
+ const totalSteps = 1 +
+ (selectedTemplateIds.length > 0 ? 1 : 0) +
+ (isNdaTemplateSelected() && ndaAttachments.length > 0 ? 1 : 0)
+ let completedSteps = 0
+
// 1단계: PQ 생성
+ setCurrentStep("PQ 생성 중...")
console.log("🚀 PQ 생성 시작")
const { error: pqError } = await requestPQVendors({
ids: vendors.map((v) => v.id),
@@ -252,9 +274,13 @@ export function RequestPQDialog({ vendors, showTrigger = true, onSuccess, ...pro })
if (pqError) {
+ setShowProgress(false)
toast.error(`PQ 생성 실패: ${pqError}`)
return
}
+
+ completedSteps++
+ setProgressValue((completedSteps / totalSteps) * 100)
console.log("✅ PQ 생성 완료")
toast.success("PQ가 성공적으로 요청되었습니다")
@@ -264,12 +290,17 @@ export function RequestPQDialog({ vendors, showTrigger = true, onSuccess, ...pro selectedTemplateIds.includes(t.id)
)
+ setCurrentStep(`기본계약서 생성 중... (${templates.length}개 템플릿)`)
console.log("📋 기본계약서 백그라운드 처리 시작", templates.length, "개 템플릿")
await processBasicContractsInBackground(templates, vendors)
+
+ completedSteps++
+ setProgressValue((completedSteps / totalSteps) * 100)
}
// 3단계: 비밀유지 계약서 첨부파일이 있는 경우 저장
if (isNdaTemplateSelected() && ndaAttachments.length > 0) {
+ setCurrentStep(`비밀유지 계약서 첨부파일 저장 중... (${ndaAttachments.length}개 파일)`)
console.log("📎 비밀유지 계약서 첨부파일 처리 시작", ndaAttachments.length, "개 파일")
const ndaResult = await saveNdaAttachments({
@@ -283,14 +314,24 @@ export function RequestPQDialog({ vendors, showTrigger = true, onSuccess, ...pro } else {
toast.error(`첨부파일 처리 중 일부 오류가 발생했습니다: ${ndaResult.error}`)
}
+
+ completedSteps++
+ setProgressValue((completedSteps / totalSteps) * 100)
}
- // 완료 후 다이얼로그 닫기
- props.onOpenChange?.(false)
- onSuccess?.()
+ setCurrentStep("완료!")
+ setProgressValue(100)
+
+ // 잠시 완료 상태를 보여준 후 다이얼로그 닫기
+ setTimeout(() => {
+ setShowProgress(false)
+ props.onOpenChange?.(false)
+ onSuccess?.()
+ }, 1000)
} catch (error) {
console.error('PQ 생성 오류:', error)
+ setShowProgress(false)
toast.error(`처리 중 오류가 발생했습니다: ${error instanceof Error ? error.message : '알 수 없는 오류'}`)
}
})
@@ -328,6 +369,13 @@ export function RequestPQDialog({ vendors, showTrigger = true, onSuccess, ...pro const template = templates[templateIndex]
processedCount++
+ // 진행률 업데이트 (2단계 범위 내에서)
+ const baseProgress = 33.33 // 1단계 완료 후
+ const contractProgress = (processedCount / totalContracts) * 33.33 // 2단계는 33.33% 차지
+ const newProgress = baseProgress + contractProgress
+ setProgressValue(newProgress)
+ setCurrentStep(`기본계약서 생성 중... (${processedCount}/${totalContracts})`)
+
console.log(`📄 처리 중: ${vendor.vendorName} - ${template.templateName} (${processedCount}/${totalContracts})`)
// 개별 벤더에 대한 기본계약 생성
@@ -720,11 +768,31 @@ export function RequestPQDialog({ vendors, showTrigger = true, onSuccess, ...pro <div className="flex-1 overflow-y-auto">
{dialogContent}
</div>
- <DialogFooter>
- <DialogClose asChild><Button variant="outline">취소</Button></DialogClose>
- <Button onClick={onApprove} disabled={isApprovePending || !type || (type === "PROJECT" && !selectedProjectId)}>
- {isApprovePending && <Loader className="mr-2 size-4 animate-spin" />}요청하기
- </Button>
+ <DialogFooter className="flex-col gap-4">
+ {/* 프로그레스 바 */}
+ {(showProgress || isApprovePending) && (
+ <div className="w-full space-y-2">
+ <div className="flex items-center justify-between text-sm">
+ <span className="text-muted-foreground">{currentStep || "처리 중..."}</span>
+ <span className="font-medium">{Math.round(progressValue)}%</span>
+ </div>
+ <Progress value={progressValue} className="w-full" />
+ </div>
+ )}
+
+ {/* 버튼들 */}
+ <div className="flex justify-end gap-2">
+ <DialogClose asChild>
+ <Button variant="outline" disabled={isApprovePending}>취소</Button>
+ </DialogClose>
+ <Button
+ onClick={onApprove}
+ disabled={isApprovePending || !type || (type === "PROJECT" && !selectedProjectId)}
+ >
+ {isApprovePending && <Loader className="mr-2 size-4 animate-spin" />}
+ 요청하기
+ </Button>
+ </div>
</DialogFooter>
</DialogContent>
@@ -753,11 +821,32 @@ export function RequestPQDialog({ vendors, showTrigger = true, onSuccess, ...pro <div className="flex-1 overflow-y-auto px-4">
{dialogContent}
</div>
- <DrawerFooter>
- <DrawerClose asChild><Button variant="outline">취소</Button></DrawerClose>
- <Button onClick={onApprove} disabled={isApprovePending || !type || (type === "PROJECT" && !selectedProjectId)}>
- {isApprovePending && <Loader className="mr-2 size-4 animate-spin" />}요청하기
- </Button>
+ <DrawerFooter className="gap-4">
+ {/* 프로그레스 바 */}
+ {(showProgress || isApprovePending) && (
+ <div className="w-full space-y-2">
+ <div className="flex items-center justify-between text-sm">
+ <span className="text-muted-foreground">{currentStep || "처리 중..."}</span>
+ <span className="font-medium">{Math.round(progressValue)}%</span>
+ </div>
+ <Progress value={progressValue} className="w-full" />
+ </div>
+ )}
+
+ {/* 버튼들 */}
+ <div className="flex gap-2">
+ <DrawerClose asChild>
+ <Button variant="outline" disabled={isApprovePending} className="flex-1">취소</Button>
+ </DrawerClose>
+ <Button
+ onClick={onApprove}
+ disabled={isApprovePending || !type || (type === "PROJECT" && !selectedProjectId)}
+ className="flex-1"
+ >
+ {isApprovePending && <Loader className="mr-2 size-4 animate-spin" />}
+ 요청하기
+ </Button>
+ </div>
</DrawerFooter>
</DrawerContent>
diff --git a/lib/vendors/table/vendors-table-toolbar-actions.tsx b/lib/vendors/table/vendors-table-toolbar-actions.tsx index 6d5f7425..3d77486d 100644 --- a/lib/vendors/table/vendors-table-toolbar-actions.tsx +++ b/lib/vendors/table/vendors-table-toolbar-actions.tsx @@ -83,7 +83,7 @@ export function VendorsTableToolbarActions({ table }: VendorsTableToolbarActions .rows .map(row => row.original) .filter(vendor => - ["PENDING_REVIEW", "IN_REVIEW", "IN_PQ", "PQ_APPROVED", "APPROVED", "READY_TO_SEND", "ACTIVE"].includes(vendor.status) + ["IN_REVIEW", "IN_PQ", "PQ_APPROVED", "APPROVED", "READY_TO_SEND", "ACTIVE"].includes(vendor.status) ); }, [table.getFilteredSelectedRowModel().rows]); |
