diff options
| author | dujinkim <dujin.kim@dtsolution.co.kr> | 2025-08-27 12:06:26 +0000 |
|---|---|---|
| committer | dujinkim <dujin.kim@dtsolution.co.kr> | 2025-08-27 12:06:26 +0000 |
| commit | 7548e2ad6948f1c6aa102fcac408bc6c9c0f9796 (patch) | |
| tree | 8e66703ec821888ad51dcc242a508813a027bf71 /lib/basic-contract/viewer | |
| parent | 7eac558470ef179dad626a8e82db5784fe86a556 (diff) | |
(대표님, 최겸) 기본계약, 입찰, 파일라우트, 계약서명라우트, 인포메이션, 메뉴설정, PQ(메일템플릿 관련)
Diffstat (limited to 'lib/basic-contract/viewer')
| -rw-r--r-- | lib/basic-contract/viewer/GtcClausesComponent.tsx | 837 | ||||
| -rw-r--r-- | lib/basic-contract/viewer/SurveyComponent.tsx | 922 | ||||
| -rw-r--r-- | lib/basic-contract/viewer/basic-contract-sign-viewer.tsx | 1515 |
3 files changed, 2159 insertions, 1115 deletions
diff --git a/lib/basic-contract/viewer/GtcClausesComponent.tsx b/lib/basic-contract/viewer/GtcClausesComponent.tsx new file mode 100644 index 00000000..8f565971 --- /dev/null +++ b/lib/basic-contract/viewer/GtcClausesComponent.tsx @@ -0,0 +1,837 @@ +"use client"; + +import React, { useState, useEffect, useCallback,useRef } from 'react'; +import { ScrollArea } from "@/components/ui/scroll-area"; +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { Textarea } from "@/components/ui/textarea"; +import { Input } from "@/components/ui/input"; +import { toast } from "sonner"; +import { + FileText, + MessageSquare, + ChevronRight, + ChevronDown, + Search, + AlertTriangle, + CheckCircle2, + Edit3, + Save, + X, + Loader2, + Hash, + BookOpen, + Minimize2, + Maximize2, +} from "lucide-react"; +import { cn } from "@/lib/utils"; +import { + getVendorGtcData, + updateVendorClause, + checkVendorClausesCommentStatus, + type GtcVendorData +} from "../service"; + +interface GtcClausesComponentProps { + contractId?: number; + onCommentStatusChange?: (hasComments: boolean, commentCount: number) => void; + t?: (key: string) => string; +} + +// GTC 조항의 기본 타입 정의 +type GtcVendorClause = { + id: number; + vendorClauseId: number | null; + baseClauseId: number; + vendorDocumentId: number | null; + parentId: number | null; + depth: number; + sortOrder: string; + fullPath: string | null; + reviewStatus: string; + negotiationNote: string | null; + isExcluded: boolean; + + // 실제 표시될 값들 (기본 조항 값) + effectiveItemNumber: string; + effectiveCategory: string | null; + effectiveSubtitle: string; + effectiveContent: string | null; + + // 기본 조항 정보 (동일) + baseItemNumber: string; + baseCategory: string | null; + baseSubtitle: string; + baseContent: string | null; + + // 수정 여부 (코멘트만 있으면 false) + hasModifications: boolean; + isNumberModified: boolean; + isCategoryModified: boolean; + isSubtitleModified: boolean; + isContentModified: boolean; + + // 코멘트 관련 + hasComment: boolean; + pendingComment: string | null; +}; + +interface ClauseState extends GtcVendorClause { + isExpanded?: boolean; + isEditing?: boolean; + tempComment?: string; + isSaving?: boolean; + // 고유 식별자를 위한 헬퍼 속성 + uniqueId: number; +} + +export function GtcClausesComponent({ + contractId, + onCommentStatusChange, + t = (key: string) => key +}: GtcClausesComponentProps) { + const [gtcData, setGtcData] = useState<GtcVendorData | null>(null); + const [clauses, setClauses] = useState<ClauseState[]>([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState<string | null>(null); + const [searchTerm, setSearchTerm] = useState(""); + const [expandedItems, setExpandedItems] = useState<Set<number>>(new Set()); + const [compactMode, setCompactMode] = useState(true); // 컴팩트 모드 상태 추가 + + const onCommentStatusChangeRef = useRef(onCommentStatusChange); + onCommentStatusChangeRef.current = onCommentStatusChange; + + // 데이터 로드 + const loadGtcData = useCallback(async () => { + try { + setLoading(true); + setError(null); + + const data = await getVendorGtcData(contractId); + + if (!data) { + setError("GTC 데이터를 찾을 수 없습니다."); + return; + } + + setGtcData(data); + + const initialClauses: ClauseState[] = data.clauses.map(clause => ({ + ...clause, + uniqueId: clause.id, + isExpanded: false, + isEditing: false, + tempComment: clause.negotiationNote || "", + isSaving: false, + })); + + setClauses(initialClauses); + + } catch (err) { + console.error('GTC 데이터 로드 실패:', err); + setError(err instanceof Error ? err.message : 'GTC 데이터를 불러오는데 실패했습니다.'); + } finally { + setLoading(false); + } + }, [contractId]); + + const lastCommentStatusRef = useRef<{ hasComments: boolean; commentCount: number } | null>(null); + + // 코멘트 상태 변경을 별도 useEffect로 처리 + useEffect(() => { + if (clauses.length > 0) { + const commentCount = clauses.filter(c => c.hasComment).length; + const hasComments = commentCount > 0; + + // Only call callback if status actually changed + const currentStatus = { hasComments, commentCount }; + if (!lastCommentStatusRef.current || + lastCommentStatusRef.current.hasComments !== hasComments || + lastCommentStatusRef.current.commentCount !== commentCount) { + + lastCommentStatusRef.current = currentStatus; + onCommentStatusChangeRef.current?.(hasComments, commentCount); + } + } + }, [clauses]); + + useEffect(() => { + loadGtcData(); + }, [loadGtcData]); + + // 검색 필터링 + const filteredClauses = React.useMemo(() => { + if (!searchTerm.trim()) return clauses; + + const term = searchTerm.toLowerCase(); + return clauses.filter(clause => + clause.effectiveItemNumber.toLowerCase().includes(term) || + clause.effectiveSubtitle.toLowerCase().includes(term) || + clause.effectiveContent?.toLowerCase().includes(term) || + clause.negotiationNote?.toLowerCase().includes(term) + ); + }, [clauses, searchTerm]); + + // 계층 구조로 조항 그룹화 + const groupedClauses = React.useMemo(() => { + const grouped: { [key: number]: ClauseState[] } = { 0: [] }; // 최상위는 0 + + filteredClauses.forEach(clause => { + // parentId를 baseClauseId와 매핑 (parentId는 실제 baseClauseId를 가리킴) + let parentKey = 0; // 기본값은 최상위 + + if (clause.parentId !== null) { + // parentId에 해당하는 조항을 찾아서 그 조항의 uniqueId를 사용 + const parentClause = filteredClauses.find(c => c.baseClauseId === clause.parentId); + if (parentClause) { + parentKey = parentClause.uniqueId; + } + } + + if (!grouped[parentKey]) { + grouped[parentKey] = []; + } + grouped[parentKey].push(clause); + }); + + // 정렬 + Object.keys(grouped).forEach(key => { + grouped[parseInt(key)].sort((a, b) => parseFloat(a.sortOrder) - parseFloat(b.sortOrder)); + }); + + return grouped; + }, [filteredClauses]); + + // 토글 확장/축소 + const toggleExpand = useCallback((uniqueId: number) => { + setExpandedItems(prev => { + const next = new Set(prev); + if (next.has(uniqueId)) { + next.delete(uniqueId); + } else { + next.add(uniqueId); + } + return next; + }); + }, []); + + // 편집 모드 토글 + const toggleEdit = useCallback((uniqueId: number) => { + setClauses(prev => prev.map(clause => { + if (clause.uniqueId === uniqueId) { + return { + ...clause, + isEditing: !clause.isEditing, + tempComment: clause.negotiationNote || "", + }; + } + return clause; + })); + }, []); + + // 임시 코멘트 업데이트 + const updateTempComment = useCallback((uniqueId: number, comment: string) => { + setClauses(prev => prev.map(clause => { + if (clause.uniqueId === uniqueId) { + return { ...clause, tempComment: comment }; + } + return clause; + })); + }, []); + + // 코멘트 저장 + const saveComment = useCallback(async (uniqueId: number) => { + const clause = clauses.find(c => c.uniqueId === uniqueId); + if (!clause) return; + + setClauses(prev => prev.map(c => + c.uniqueId === uniqueId ? { ...c, isSaving: true } : c + )); + + try { + // 기본 조항 정보를 그대로 사용하고 코멘트만 처리 + const clauseData = { + itemNumber: clause.effectiveItemNumber, + category: clause.effectiveCategory, + subtitle: clause.effectiveSubtitle, + content: clause.effectiveContent, + comment: clause.tempComment || "", + }; + + const result = await updateVendorClause( + clause.id, + clause.vendorClauseId, + clauseData, + gtcData?.vendorDocument + ); + + if (result.success) { + const hasComment = !!(clause.tempComment?.trim()); + + setClauses(prev => prev.map(c => { + if (c.uniqueId === uniqueId) { + return { + ...c, + vendorClauseId: result.vendorClauseId || c.vendorClauseId, + negotiationNote: clause.tempComment?.trim() || null, + hasComment, + isEditing: false, + isSaving: false, + }; + } + return c; + })); + + toast.success("코멘트가 저장되었습니다."); + + } else { + toast.error(result.error || "코멘트 저장에 실패했습니다."); + setClauses(prev => prev.map(c => + c.uniqueId === uniqueId ? { ...c, isSaving: false } : c + )); + } + } catch (error) { + console.error('코멘트 저장 실패:', error); + toast.error("코멘트 저장 중 오류가 발생했습니다."); + setClauses(prev => prev.map(c => + c.uniqueId === uniqueId ? { ...c, isSaving: false } : c + )); + } + }, [clauses, gtcData]); + + // 편집 취소 + const cancelEdit = useCallback((uniqueId: number) => { + setClauses(prev => prev.map(clause => { + if (clause.uniqueId === uniqueId) { + return { + ...clause, + isEditing: false, + tempComment: clause.negotiationNote || "", + }; + } + return clause; + })); + }, []); + + // 컴팩트 모드 렌더링 + const renderCompactClause = useCallback((clause: ClauseState, depth: number = 0): React.ReactNode => { + const isExpanded = expandedItems.has(clause.uniqueId); + const children = groupedClauses[clause.uniqueId] || []; + const hasChildren = children.length > 0; + + return ( + <div key={clause.uniqueId} className={`${depth > 0 ? 'ml-4' : ''}`}> + <div className={cn( + "flex items-center justify-between p-2 rounded border transition-all duration-200 hover:bg-gray-50 mb-1", + clause.hasComment && "border-amber-200 bg-amber-50", + clause.isExcluded && "opacity-50 border-gray-300" + )}> + <div className="flex items-center space-x-2 flex-1 min-w-0"> + {/* 확장/축소 버튼 */} + <Button + variant="ghost" + size="sm" + className="h-5 w-5 p-0 flex-shrink-0" + onClick={() => toggleExpand(clause.uniqueId)} + > + {isExpanded ? ( + <ChevronDown className="h-3 w-3" /> + ) : ( + <ChevronRight className="h-3 w-3" /> + )} + </Button> + + {/* 조항 번호 */} + <span className="font-mono text-blue-600 text-sm flex-shrink-0 min-w-0 font-medium"> + {clause.effectiveItemNumber} + </span> + + {/* 제목 */} + <span className="text-sm text-gray-800 truncate flex-1 min-w-0"> + {clause.effectiveSubtitle} + </span> + + {/* 상태 표시 */} + <div className="flex items-center space-x-1 flex-shrink-0"> + {/* {clause.hasComment && ( + <Badge variant="outline" className="text-xs px-1.5 py-0.5 h-5 bg-amber-50 text-amber-600 border-amber-200"> + <MessageSquare className="h-2.5 w-2.5" /> + </Badge> + )} */} + {clause.isExcluded && ( + <Badge variant="outline" className="text-xs px-1.5 py-0.5 h-5 bg-gray-50 text-gray-500 border-gray-300"> + 제외 + </Badge> + )} + </div> + </div> + + {/* 편집 버튼 */} + <div className="flex items-center space-x-1 flex-shrink-0 ml-2"> + {clause.isEditing ? ( + <div className="flex items-center space-x-1"> + <Button + variant="ghost" + size="sm" + onClick={() => saveComment(clause.uniqueId)} + disabled={clause.isSaving} + className="h-6 w-6 p-0 text-green-600 hover:text-green-700 hover:bg-green-50" + > + {clause.isSaving ? ( + <Loader2 className="h-3 w-3 animate-spin" /> + ) : ( + <Save className="h-3 w-3" /> + )} + </Button> + <Button + variant="ghost" + size="sm" + onClick={() => cancelEdit(clause.uniqueId)} + disabled={clause.isSaving} + className="h-6 w-6 p-0 text-gray-500 hover:text-gray-700 hover:bg-gray-50" + > + <X className="h-3 w-3" /> + </Button> + </div> + ) : ( + <Button + variant="ghost" + size="sm" + onClick={() => toggleEdit(clause.uniqueId)} + className={cn( + "h-6 w-6 p-0 transition-colors", + clause.hasComment + ? "text-amber-600 hover:text-amber-700 hover:bg-amber-50" + : "text-gray-500 hover:text-gray-700 hover:bg-gray-50" + )} + > + {clause.hasComment ? ( + <MessageSquare className="h-3 w-3" /> + ) : ( + <Edit3 className="h-3 w-3" /> + )} + </Button> + )} + </div> + </div> + + {/* 확장된 내용 */} + {isExpanded && ( + <div className="mt-1 ml-5 p-3 bg-white rounded border border-gray-200"> + {/* 카테고리 */} + {clause.effectiveCategory && ( + <div className="mb-2"> + <span className="text-sm text-gray-500 font-medium">카테고리: </span> + <span className="text-sm text-gray-700">{clause.effectiveCategory}</span> + </div> + )} + + {/* 내용 */} + {clause.effectiveContent && ( + <p className="text-sm text-gray-700 leading-relaxed mb-3 whitespace-pre-wrap"> + {clause.effectiveContent} + </p> + )} + + {/* 코멘트 편집 영역 */} + {clause.isEditing && ( + <div className="mb-3 p-2.5 bg-amber-50 rounded border border-amber-200"> + <div className="flex items-center text-sm font-medium text-amber-800 mb-2"> + <MessageSquare className="h-4 w-4 mr-2" /> + 협의 코멘트 + </div> + <Textarea + value={clause.tempComment || ""} + onChange={(e) => updateTempComment(clause.uniqueId, e.target.value)} + placeholder="이 조항에 대한 의견이나 수정 요청 사항을 입력해주세요..." + className="min-h-[60px] text-sm bg-white border-amber-200 focus:border-amber-300" + disabled={clause.isSaving} + /> + <p className="text-xs text-amber-600 mt-1"> + 코멘트를 입력하면 이 계약서는 서명할 수 없게 됩니다. + </p> + </div> + )} + + {/* 기존 코멘트 표시 */} + {!clause.isEditing && clause.hasComment && clause.negotiationNote && ( + <div className="mb-2 p-2.5 bg-amber-50 rounded border border-amber-200"> + <div className="flex items-center text-sm font-medium text-amber-800 mb-2"> + <MessageSquare className="h-4 w-4 mr-2" /> + 협의 코멘트 + </div> + <p className="text-sm text-amber-700 whitespace-pre-wrap"> + {clause.negotiationNote} + </p> + </div> + )} + </div> + )} + + {/* 자식 조항들 */} + {hasChildren && ( + <div className={isExpanded ? "mt-1 border-l border-gray-200 pl-3" : ""}> + {children.map(child => renderCompactClause(child, depth + 1))} + </div> + )} + </div> + ); + }, [expandedItems, groupedClauses, toggleExpand, saveComment, cancelEdit, toggleEdit, updateTempComment]); + + // 기본 모드 렌더링 (기존 코드 최적화) + const renderNormalClause = useCallback((clause: ClauseState, depth: number = 0): React.ReactNode => { + const isExpanded = expandedItems.has(clause.uniqueId); + const children = groupedClauses[clause.uniqueId] || []; + const hasChildren = children.length > 0; + + return ( + <div key={clause.uniqueId} className={`mb-1 ${depth > 0 ? 'ml-4' : ''}`}> + <Card className={cn( + "transition-all duration-200", + clause.hasComment && "border-amber-200 bg-amber-50", + clause.isExcluded && "opacity-50 border-gray-300" + )}> + <CardHeader className="pb-1 pt-2 px-3"> + <div className="flex items-start justify-between"> + <div className="flex items-start space-x-2 flex-1 min-w-0"> + {/* 확장/축소 버튼 */} + <Button + variant="ghost" + size="sm" + className="h-5 w-5 p-0 flex-shrink-0" + onClick={() => toggleExpand(clause.uniqueId)} + > + {isExpanded ? ( + <ChevronDown className="h-3 w-3" /> + ) : ( + <ChevronRight className="h-3 w-3" /> + )} + </Button> + + <div className="flex-1 min-w-0"> + <CardTitle className="text-sm font-medium text-gray-800 flex items-center"> + <Hash className="h-3 w-3 mr-1 text-blue-500" /> + {clause.effectiveItemNumber} + {clause.hasComment && ( + <Badge variant="outline" className="ml-2 text-xs bg-amber-50 text-amber-700 border-amber-200"> + <MessageSquare className="h-2 w-2 mr-1" /> + 코멘트 + </Badge> + )} + {clause.isExcluded && ( + <Badge variant="outline" className="ml-2 text-xs bg-gray-50 text-gray-500 border-gray-300"> + 제외됨 + </Badge> + )} + </CardTitle> + <p className="text-sm font-medium text-gray-700 mt-0.5"> + {clause.effectiveSubtitle} + </p> + </div> + + {/* 코멘트 버튼 */} + <div className="flex items-center space-x-0.5 flex-shrink-0"> + {clause.isEditing ? ( + <div className="flex items-center space-x-0.5"> + <Button + variant="ghost" + size="sm" + onClick={() => saveComment(clause.uniqueId)} + disabled={clause.isSaving} + className="h-6 px-2 text-green-600 hover:text-green-700 hover:bg-green-50" + > + {clause.isSaving ? ( + <Loader2 className="h-3 w-3 animate-spin" /> + ) : ( + <Save className="h-3 w-3" /> + )} + </Button> + <Button + variant="ghost" + size="sm" + onClick={() => cancelEdit(clause.uniqueId)} + disabled={clause.isSaving} + className="h-6 px-2 text-gray-500 hover:text-gray-700 hover:bg-gray-50" + > + <X className="h-3 w-3" /> + </Button> + </div> + ) : ( + <Button + variant="ghost" + size="sm" + onClick={() => toggleEdit(clause.uniqueId)} + className={cn( + "h-6 px-2 transition-colors", + clause.hasComment + ? "text-amber-600 hover:text-amber-700 hover:bg-amber-50" + : "text-gray-500 hover:text-gray-700 hover:bg-gray-50" + )} + > + {clause.hasComment ? ( + <MessageSquare className="h-3 w-3" /> + ) : ( + <Edit3 className="h-3 w-3" /> + )} + </Button> + )} + </div> + </div> + </div> + </CardHeader> + + {isExpanded && ( + <CardContent className="pt-0 px-3 pb-2"> + {/* 카테고리 */} + {clause.effectiveCategory && ( + <div className="mb-2"> + <div className="flex items-center text-xs text-gray-500 mb-1"> + <BookOpen className="h-3 w-3 mr-1" /> + 카테고리 + </div> + <p className="text-sm text-gray-700 bg-gray-50 p-2 rounded"> + {clause.effectiveCategory} + </p> + </div> + )} + + {/* 내용 */} + {clause.effectiveContent && ( + <div className="mb-2"> + <div className="flex items-center text-xs text-gray-500 mb-1"> + <FileText className="h-3 w-3 mr-1" /> + 내용 + </div> + <p className="text-sm text-gray-700 bg-gray-50 p-2 rounded whitespace-pre-wrap leading-tight"> + {clause.effectiveContent} + </p> + </div> + )} + + {/* 코멘트 편집 영역 */} + {clause.isEditing && ( + <div className="mb-2 p-2 bg-amber-50 rounded border border-amber-200"> + <div className="flex items-center text-sm font-medium text-amber-800 mb-1"> + <MessageSquare className="h-4 w-4 mr-2" /> + 협의 코멘트 + </div> + <Textarea + value={clause.tempComment || ""} + onChange={(e) => updateTempComment(clause.uniqueId, e.target.value)} + placeholder="이 조항에 대한 의견이나 수정 요청 사항을 입력해주세요..." + className="min-h-[60px] text-sm bg-white border-amber-200 focus:border-amber-300" + disabled={clause.isSaving} + /> + <p className="text-xs text-amber-600 mt-1"> + 코멘트를 입력하면 이 계약서는 서명할 수 없게 됩니다. + </p> + </div> + )} + + {/* 기존 코멘트 표시 */} + {!clause.isEditing && clause.hasComment && clause.negotiationNote && ( + <div className="mb-2 p-2 bg-amber-50 rounded border border-amber-200"> + <div className="flex items-center text-sm font-medium text-amber-800 mb-1"> + <MessageSquare className="h-4 w-4 mr-2" /> + 협의 코멘트 + </div> + <p className="text-sm text-amber-700 whitespace-pre-wrap"> + {clause.negotiationNote} + </p> + </div> + )} + + {/* 자식 조항들 */} + {hasChildren && ( + <div className="mt-2 border-l-2 border-gray-200 pl-2"> + {children.map(child => renderNormalClause(child, depth + 1))} + </div> + )} + </CardContent> + )} + </Card> + + {/* 확장되지 않았을 때 자식 조항들 */} + {!isExpanded && hasChildren && ( + <div className="ml-4"> + {children.map(child => renderNormalClause(child, depth + 1))} + </div> + )} + </div> + ); + }, [expandedItems, groupedClauses, toggleExpand, saveComment, cancelEdit, toggleEdit, updateTempComment]); + + if (loading) { + return ( + <div className="flex items-center justify-center h-64"> + <div className="text-center"> + <Loader2 className="h-8 w-8 text-blue-500 animate-spin mx-auto mb-4" /> + <p className="text-sm text-gray-500">GTC 조항을 불러오는 중...</p> + </div> + </div> + ); + } + + if (error) { + return ( + <div className="flex items-center justify-center h-64"> + <div className="text-center"> + <AlertTriangle className="h-8 w-8 text-red-500 mx-auto mb-4" /> + <p className="text-sm text-gray-700 font-medium mb-2">GTC 데이터 로드 실패</p> + <p className="text-sm text-gray-500 mb-4">{error}</p> + <Button onClick={loadGtcData} size="sm"> + 다시 시도 + </Button> + </div> + </div> + ); + } + + if (!gtcData) { + return ( + <div className="flex items-center justify-center h-64"> + <div className="text-center"> + <FileText className="h-8 w-8 text-gray-400 mx-auto mb-4" /> + <p className="text-sm text-gray-500">GTC 데이터가 없습니다.</p> + </div> + </div> + ); + } + + const totalComments = clauses.filter(c => c.hasComment).length; + + return ( + <div className="h-full flex flex-col"> + {/* 헤더 */} + <div className={cn( + "flex-shrink-0 border-b bg-gray-50", + compactMode ? "p-2.5" : "p-3" + )}> + <div className={cn( + "flex items-center justify-between", + compactMode ? "mb-2" : "mb-2" + )}> + <div className="flex-1 min-w-0"> + <h3 className={cn( + "font-semibold text-gray-800 flex items-center", + compactMode ? "text-sm" : "text-base" + )}> + <FileText className={cn( + "mr-2 text-blue-500", + compactMode ? "h-4 w-4" : "h-5 w-5" + )} /> + {gtcData.vendorDocument.name} + </h3> + {!compactMode && ( + <p className="text-sm text-gray-500 mt-0.5"> + {gtcData.vendorDocument.description || "GTC 조항 검토 및 협의"} + </p> + )} + </div> + <div className="flex items-center space-x-1.5"> + {/* 모드 전환 버튼 */} + <Button + variant="ghost" + size="sm" + onClick={() => setCompactMode(!compactMode)} + className={cn( + "transition-colors", + compactMode ? "h-7 px-2" : "h-7 px-2" + )} + title={compactMode ? "일반 모드로 전환" : "컴팩트 모드로 전환"} + > + {compactMode ? ( + <Maximize2 className="h-3 w-3" /> + ) : ( + <Minimize2 className="h-3 w-3" /> + )} + </Button> + + <Badge variant="outline" className={cn( + "bg-blue-50 text-blue-700 border-blue-200", + compactMode ? "text-xs px-1.5 py-0.5" : "text-xs" + )}> + 총 {clauses.length}개 조항 + </Badge> + {totalComments > 0 && ( + <Badge variant="outline" className={cn( + "bg-amber-50 text-amber-700 border-amber-200", + compactMode ? "text-xs px-1.5 py-0.5" : "text-xs" + )}> + <MessageSquare className={cn( + "mr-1", + compactMode ? "h-2.5 w-2.5" : "h-3 w-3" + )} /> + {totalComments}개 코멘트 + </Badge> + )} + </div> + </div> + + {/* 검색 */} + <div className="relative"> + <div className="absolute inset-y-0 left-2 flex items-center pointer-events-none"> + <Search className={cn( + "text-gray-400", + compactMode ? "h-3.5 w-3.5" : "h-4 w-4" + )} /> + </div> + <Input + placeholder={compactMode ? "조항 검색..." : "조항 번호, 제목, 내용, 코멘트로 검색..."} + className={cn( + "bg-white text-gray-700", + compactMode ? "pl-8 text-sm h-8" : "pl-8 text-sm" + )} + value={searchTerm} + onChange={(e) => setSearchTerm(e.target.value)} + /> + </div> + + {/* 안내 메시지 */} + {totalComments > 0 && ( + <div className={cn( + "bg-amber-50 rounded border border-amber-200", + compactMode ? "mt-2 p-2" : "mt-2 p-2" + )}> + <div className={cn( + "flex items-center text-amber-800", + compactMode ? "text-sm" : "text-sm" + )}> + <AlertTriangle className={cn( + "mr-2", + compactMode ? "h-4 w-4" : "h-4 w-4" + )} /> + <span className="font-medium">코멘트가 있어 서명할 수 없습니다.</span> + </div> + {!compactMode && ( + <p className="text-sm text-amber-700 mt-0.5"> + 모든 코멘트를 삭제하거나 협의를 완료한 후 서명해주세요. + </p> + )} + </div> + )} + </div> + + {/* 조항 목록 */} + <ScrollArea className="flex-1"> + <div className={compactMode ? "p-2.5" : "p-3"}> + {filteredClauses.length === 0 ? ( + <div className="text-center py-6"> + <FileText className="h-6 w-6 text-gray-300 mx-auto mb-2" /> + <p className="text-sm text-gray-500"> + {searchTerm ? "검색 결과가 없습니다." : "조항이 없습니다."} + </p> + </div> + ) : ( + <div className={compactMode ? "space-y-0.5" : "space-y-1"}> + {(groupedClauses[0] || []).map(clause => + compactMode ? renderCompactClause(clause) : renderNormalClause(clause) + )} + </div> + )} + </div> + </ScrollArea> + </div> + ); +}
\ No newline at end of file diff --git a/lib/basic-contract/viewer/SurveyComponent.tsx b/lib/basic-contract/viewer/SurveyComponent.tsx new file mode 100644 index 00000000..299fe6fa --- /dev/null +++ b/lib/basic-contract/viewer/SurveyComponent.tsx @@ -0,0 +1,922 @@ +import React, { useState, useEffect, useRef, useMemo, useCallback } from "react"; +import { useForm, useWatch, Controller } from "react-hook-form"; +import { Loader2, FileText, ClipboardList, AlertTriangle, CheckCircle2, Upload, ChevronDown, ChevronUp } from "lucide-react"; +import { toast } from "sonner"; +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; +import { Label } from "@/components/ui/label"; +import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group"; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; +import { Input } from "@/components/ui/input"; +import { Textarea } from "@/components/ui/textarea"; +import { + CompleteSurveyRequest, + ExistingResponse, + SurveyAnswerData, + completeSurvey, + getExistingSurveyResponse, + type SurveyTemplateWithQuestions +} from '../service'; +import { ConditionalSurveyHandler } from '../vendor-table/survey-conditional'; + +// 폼 데이터 타입 정의 +interface SurveyFormData { + [key: string]: { + answerValue?: string; + detailText?: string; + otherText?: string; + files?: File[]; + }; +} + +interface SurveyComponentProps { + contractId?: number; + surveyTemplate: SurveyTemplateWithQuestions | null; + surveyLoading: boolean; + conditionalHandler: ConditionalSurveyHandler | null; + onSurveyComplete?: () => void; + onSurveyDataUpdate: (data: any) => void; + onLoadSurveyTemplate: () => void; + setActiveTab: (tab: string) => void; +} + +export const SurveyComponent: React.FC<SurveyComponentProps> = ({ + contractId, + surveyTemplate, + surveyLoading, + conditionalHandler, + onSurveyComplete, + onSurveyDataUpdate, + onLoadSurveyTemplate, + setActiveTab +}) => { + const [uploadedFiles, setUploadedFiles] = useState<Record<number, File[]>>({}); + const [existingResponse, setExistingResponse] = useState<ExistingResponse | null>(null); + const [loadingExistingResponse, setLoadingExistingResponse] = useState(false); + const [isSubmitting, setIsSubmitting] = useState(false); + const [formInitialized, setFormInitialized] = useState(false); + const [isHeaderExpanded, setIsHeaderExpanded] = useState(false); + + console.log(uploadedFiles,"uploadedFiles") + + // 무한 렌더링 방지를 위한 ref + const loadingRef = useRef(false); + const initializedRef = useRef(false); + + // 기본 폼 설정 - 의존성 최소화 + const { control, watch, setValue, getValues, formState: { errors }, trigger, reset } = useForm<SurveyFormData>({ + defaultValues: {}, + mode: 'onChange' + }); + + const watchedValues = watch(); + + + // 기존 응답 로드 - 한 번만 실행되도록 최적화 + useEffect(() => { + // 중복 실행 방지 + if (loadingRef.current || !contractId || !surveyTemplate?.id || initializedRef.current) { + return; + } + + loadingRef.current = true; + setLoadingExistingResponse(true); + + const loadExistingResponse = async () => { + try { + console.log('📥 기존 설문 응답 조회 시작...', { contractId, templateId: surveyTemplate.id }); + + const result = await getExistingSurveyResponse(contractId, surveyTemplate.id); + + if (result.success && result.data) { + console.log('✅ 기존 응답 발견:', result.data); + setExistingResponse(result.data); + + // 폼 초기값 설정 + const formValues: SurveyFormData = {}; + const existingFiles: Record<number, File[]> = {}; + + result.data.answers.forEach(answer => { + formValues[answer.questionId] = { + answerValue: answer.answerValue || '', + detailText: answer.detailText || '', + otherText: answer.otherText || '', + files: answer.files || [], // 기존 파일 정보 유지 + }; + + // 파일이 있다면 uploadedFiles에도 설정 (표시용) + if (answer.files && answer.files.length > 0) { + existingFiles[answer.questionId] = answer.files; + } + }); + + console.log('📝 폼 초기값 설정:', formValues); + + // reset을 사용하여 폼 전체를 한 번에 초기화 + reset(formValues); + setUploadedFiles(existingFiles) + + // 기존 응답이 완료되었다면 부모에게 알림 + if (result.data.status === 'COMPLETED') { + const completedData = { + completed: true, + answers: result.data.answers, + timestamp: result.data.completedAt || new Date().toISOString(), + responseId: result.data.responseId, + }; + + onSurveyDataUpdate(completedData); + + // ⭐ 여기가 핵심: 기존 완료된 설문이 있으면 즉시 부모의 설문 완료 상태 업데이트 + if (onSurveyComplete) { + console.log(`📋 기존 완료된 설문조사 감지 - 부모 상태 업데이트: 계약서 ${contractId}`); + onSurveyComplete(); + } + } + + } else { + console.log('📭 기존 응답 없음'); + setExistingResponse(null); + } + + initializedRef.current = true; + setFormInitialized(true); + + } catch (error) { + console.error('❌ 기존 응답 로드 중 오류:', error); + setExistingResponse(null); + initializedRef.current = true; + setFormInitialized(true); + } finally { + setLoadingExistingResponse(false); + loadingRef.current = false; + } + }; + + loadExistingResponse(); + }, [contractId, surveyTemplate?.id,onSurveyComplete]); // 의존성 최소화 + + // 실시간 진행 상태 계산 - 안정화 + const progressStatus = useMemo(() => { + if (!formInitialized || !conditionalHandler || !surveyTemplate) { + return { + visibleQuestions: [], + totalRequired: 0, + completedRequired: 0, + completedQuestionIds: [], + incompleteQuestionIds: [], + progressPercentage: 0, + debugInfo: {} + }; + } + + // 현재 답변을 조건부 핸들러가 인식할 수 있는 형태로 변환 + const convertedAnswers: Record<number, any> = {}; + Object.entries(watchedValues || {}).forEach(([questionId, value]) => { + const id = parseInt(questionId); + if (!isNaN(id) && value) { + convertedAnswers[id] = { + questionId: id, + answerValue: value.answerValue || '', + detailText: value.detailText || '', + otherText: value.otherText || '', + files: value.files || [] + }; + } + }); + + console.log(convertedAnswers,"convertedAnswers") + + return conditionalHandler.getSimpleProgressStatus(convertedAnswers); + }, [conditionalHandler, watchedValues, surveyTemplate, formInitialized]); + + console.log(progressStatus,"progressStatus") + + // 동적 상태 정보 - 메모화 + const { + visibleQuestions, + totalVisibleQuestions, + baseQuestionCount, + conditionalQuestionCount, + hasConditionalQuestions, + canComplete + } = useMemo(() => { + const visibleQuestions = progressStatus.visibleQuestions; + const totalVisibleQuestions = visibleQuestions.length; + const baseQuestionCount = surveyTemplate?.questions.length || 0; + const conditionalQuestionCount = totalVisibleQuestions - baseQuestionCount; + const hasConditionalQuestions = conditionalQuestionCount > 0; + const canComplete = progressStatus.totalRequired > 0 && + progressStatus.completedRequired === progressStatus.totalRequired; + + return { + visibleQuestions, + totalVisibleQuestions, + baseQuestionCount, + conditionalQuestionCount, + hasConditionalQuestions, + canComplete + }; + }, [progressStatus, surveyTemplate?.questions]); + + // 파일 업로드 핸들러 - 안정적인 참조 + const handleFileUpload = useCallback((questionId: number, files: FileList | null) => { + if (!files) return; + + const fileArray = Array.from(files); + setUploadedFiles(prev => ({ + ...prev, + [questionId]: fileArray + })); + + setValue(`${questionId}.files`, fileArray); + }, [setValue]); + + // 답변 변경 핸들러 - 안정적인 참조 + const handleAnswerChange = useCallback((questionId: number, field: string, value: any) => { + console.log(`📝 답변 변경: 질문 ${questionId}, 필드 ${field}, 값:`, value); + + setValue(`${questionId}.${field}`, value); + + // 부모 질문의 답변이 변경되면 조건부 자식 질문들 처리 + if (field === 'answerValue' && conditionalHandler) { + // setTimeout으로 다음 tick에서 처리하여 상태 업데이트 충돌 방지 + setTimeout(() => { + const currentValues = getValues(); + const convertedAnswers: Record<number, any> = {}; + + Object.entries(currentValues).forEach(([qId, qValue]) => { + const id = parseInt(qId); + if (!isNaN(id) && qValue) { + convertedAnswers[id] = { + questionId: id, + answerValue: qValue.answerValue || '', + detailText: qValue.detailText || '', + otherText: qValue.otherText || '', + files: qValue.files || [] + }; + } + }); + + // 새로운 답변 반영 + convertedAnswers[questionId] = { + ...convertedAnswers[questionId], + questionId, + [field]: value + }; + + // 영향받는 자식 질문들의 답변 초기화 + const clearedAnswers = conditionalHandler.clearAffectedChildAnswers(questionId, value, convertedAnswers); + + // 삭제된 답변들을 폼에서도 제거 + Object.keys(convertedAnswers).forEach(qId => { + const id = parseInt(qId); + if (id !== questionId && !clearedAnswers[id]) { + console.log(`🗑️ 질문 ${id} 답변 초기화`); + setValue(`${id}`, { + answerValue: '', + detailText: '', + otherText: '', + files: [] + }); + + setUploadedFiles(prev => { + const updated = { ...prev }; + delete updated[id]; + return updated; + }); + } + }); + }, 0); + } + }, [setValue, getValues, conditionalHandler]); + + // OTHER 텍스트 입력 컴포넌트 + const OtherTextInput = useCallback(({ questionId, fieldName }: { questionId: number; fieldName: string }) => { + const answerValue = useWatch({ + control, + name: `${fieldName}.answerValue` + }); + + const question = visibleQuestions.find(q => q.id === questionId); + const selectedOption = question?.options?.find(opt => opt.optionValue === answerValue); + + if (!selectedOption?.allowsOtherInput) return null; + + return ( + <Controller + name={`${fieldName}.otherText`} + control={control} + render={({ field }) => ( + <Input + {...field} + placeholder="기타 내용을 입력해주세요" + className="mt-2" + /> + )} + /> + ); + }, [control, visibleQuestions]); + + // 설문조사 완료 핸들러 + const handleSurveyComplete = useCallback(async () => { + console.log('🎯 설문조사 완료 시도'); + + if (isSubmitting) { + console.log('⚠️ 이미 제출 중...'); + return; + } + + setIsSubmitting(true); + + try { + const currentValues = getValues(); + const isValid = await trigger(); + + if (!canComplete) { + let errorMessage = '모든 필수 항목을 완료해주세요.'; + let errorDescription = `${progressStatus.completedRequired}/${progressStatus.totalRequired} 완료됨`; + + if (progressStatus.incompleteQuestionIds.length > 0) { + const incompleteReasons = progressStatus.incompleteQuestionIds.map(id => { + const debug = progressStatus.debugInfo?.[id]; + const question = visibleQuestions.find(q => q.id === id); + return `• Q${question?.questionNumber}: ${question?.questionText?.substring(0, 40)}...\n → ${debug?.incompleteReason || '답변 필요'}`; + }).slice(0, 3); + + errorDescription = incompleteReasons.join('\n\n'); + + if (progressStatus.incompleteQuestionIds.length > 3) { + errorDescription += `\n\n... 외 ${progressStatus.incompleteQuestionIds.length - 3}개 항목`; + } + } + + toast.error(errorMessage, { + description: errorDescription, + icon: <AlertTriangle className="h-5 w-5 text-red-500" />, + duration: 12000 + }); + + if (progressStatus.incompleteQuestionIds.length > 0) { + const firstIncompleteId = progressStatus.incompleteQuestionIds[0]; + const element = document.getElementById(`question-${firstIncompleteId}`); + if (element) { + element.scrollIntoView({ behavior: 'smooth', block: 'center' }); + } + } + + return; + } + + if (!contractId || !surveyTemplate?.id) { + toast.error('계약서 정보 또는 설문 템플릿 정보가 없습니다.'); + return; + } + + const surveyAnswers: SurveyAnswerData[] = Object.entries(currentValues) + .map(([questionId, value]) => ({ + questionId: parseInt(questionId), + answerValue: value?.answerValue || '', + detailText: value?.detailText || '', + otherText: value?.otherText || '', + files: value?.files || [] + })) + .filter(answer => + answer.answerValue || answer.detailText || (answer.files && answer.files.length > 0) + ); + + const requestData: CompleteSurveyRequest = { + contractId: contractId, + templateId: surveyTemplate.id, + answers: surveyAnswers, + progressStatus: progressStatus + }; + + const submitToast = toast.loading('설문조사를 저장하는 중...', { + description: '잠시만 기다려주세요.', + duration: Infinity + }); + + const result = await completeSurvey(requestData); + toast.dismiss(submitToast); + + if (result.success) { + const completedSurveyData = { + completed: true, + answers: surveyAnswers, + timestamp: new Date().toISOString(), + progressStatus: progressStatus, + totalQuestions: totalVisibleQuestions, + conditionalQuestions: conditionalQuestionCount, + responseId: result.data?.responseId + }; + + onSurveyDataUpdate(completedSurveyData); + + if (onSurveyComplete) { + onSurveyComplete(); + } + + toast.success("🎉 설문조사가 완료되었습니다!", { + description: `총 ${progressStatus.totalRequired}개 필수 질문 완료${hasConditionalQuestions ? ` (조건부 ${conditionalQuestionCount}개 포함)` : ''}`, + icon: <CheckCircle2 className="h-5 w-5 text-green-500" />, + duration: 5000 + }); + + setTimeout(() => { + setActiveTab('main'); + }, 2000); + + } else { + console.error('❌ 서버 응답 에러:', result.message); + toast.error('설문조사 저장 실패', { + description: result.message || '서버에서 오류가 발생했습니다.', + icon: <AlertTriangle className="h-5 w-5 text-red-500" />, + duration: 8000 + }); + } + + } catch (error) { + console.error('❌ 설문조사 저장 중 예외 발생:', error); + + let errorMessage = '설문조사 저장에 실패했습니다.'; + let errorDescription = '네트워크 연결을 확인하고 다시 시도해주세요.'; + + if (error instanceof Error) { + errorDescription = error.message; + } + + toast.error(errorMessage, { + description: errorDescription, + icon: <AlertTriangle className="h-5 w-5 text-red-500" />, + duration: 10000 + }); + } finally { + setIsSubmitting(false); + } + }, [ + getValues, + trigger, + canComplete, + progressStatus, + visibleQuestions, + contractId, + surveyTemplate?.id, + totalVisibleQuestions, + conditionalQuestionCount, + hasConditionalQuestions, + isSubmitting, + onSurveyComplete, + onSurveyDataUpdate, + setActiveTab + ]); + + if (surveyLoading || loadingExistingResponse) { + return ( + <div className="h-full w-full"> + <Card className="h-full"> + <CardContent className="flex flex-col items-center justify-center h-full py-12"> + <Loader2 className="h-8 w-8 text-blue-500 animate-spin mb-4" /> + <p className="text-sm text-muted-foreground"> + {surveyLoading ? '설문조사를 불러오는 중...' : '기존 응답을 확인하는 중...'} + </p> + </CardContent> + </Card> + </div> + ); + } + + if (!surveyTemplate) { + return ( + <div className="h-full w-full"> + <Card className="h-full"> + <CardContent className="flex flex-col items-center justify-center h-full py-12"> + <AlertTriangle className="h-8 w-8 text-red-500 mb-4" /> + <p className="text-sm text-muted-foreground">설문조사 템플릿을 불러올 수 없습니다.</p> + <Button + variant="outline" + onClick={onLoadSurveyTemplate} + className="mt-2" + > + 다시 시도 + </Button> + </CardContent> + </Card> + </div> + ); + } + + return ( + <div className="h-full w-full flex flex-col"> + <Card className="h-full flex flex-col"> + <CardHeader className="pb-2"> + {/* 항상 보이는 컴팩트 헤더 */} + <div className="flex items-center justify-between"> + <CardTitle className="flex items-center text-lg"> + <ClipboardList className="h-5 w-5 mr-2 text-amber-500" /> + <span className="truncate max-w-[300px]">{surveyTemplate.name}</span> + {existingResponse && ( + <Badge + variant={existingResponse.status === 'COMPLETED' ? 'default' : 'secondary'} + className="ml-2 text-xs" + > + {existingResponse.status === 'COMPLETED' ? '완료됨' : '작성중'} + </Badge> + )} + </CardTitle> + + <div className="flex items-center space-x-3"> + {/* 컴팩트 진행률 표시 */} + <div className="flex items-center space-x-2"> + <div className="w-20 bg-gray-200 rounded-full h-1.5"> + <div + className="bg-gradient-to-r from-blue-500 to-blue-600 h-1.5 rounded-full transition-all duration-300" + style={{ width: `${progressStatus.progressPercentage}%` }} + /> + </div> + <span className="text-xs text-gray-600 whitespace-nowrap"> + {progressStatus.completedRequired}/{progressStatus.totalRequired} + </span> + </div> + + {/* 펼치기/접기 버튼 */} + <Button + variant="ghost" + size="sm" + onClick={() => setIsHeaderExpanded(!isHeaderExpanded)} + className="p-1 h-8 w-8" + > + {isHeaderExpanded ? ( + <ChevronUp className="h-4 w-4" /> + ) : ( + <ChevronDown className="h-4 w-4" /> + )} + </Button> + </div> + </div> + + {/* 접을 수 있는 상세 정보 - 조건부 렌더링 */} + {isHeaderExpanded && ( + <div className="mt-3 pt-3 space-y-3 border-t"> + {/* 기존 응답 정보 */} + {existingResponse && ( + <div className="p-2 bg-blue-50 border border-blue-200 rounded-lg"> + <div className="flex items-center text-sm"> + <CheckCircle2 className="h-4 w-4 text-blue-600 mr-2" /> + <span className="text-blue-800"> + {existingResponse.status === 'COMPLETED' + ? '이미 완료된 설문조사입니다. 내용을 수정할 수 있습니다.' + : '이전에 작성하던 내용이 복원되었습니다.'} + </span> + </div> + {existingResponse.completedAt && ( + <div className="text-xs text-blue-600 mt-1"> + 완료일시: {new Date(existingResponse.completedAt).toLocaleString('ko-KR')} + </div> + )} + </div> + )} + + {/* 질문 정보 - 그리드로 컴팩트하게 */} + {/* <div className="grid grid-cols-2 gap-4 text-sm"> + <div className="space-y-1"> + <div className="text-gray-600"> + 📋 총 {totalVisibleQuestions}개 질문 + </div> + {hasConditionalQuestions && ( + <div className="text-blue-600 text-xs"> + ⚡ 조건부 {conditionalQuestionCount}개 추가됨 + </div> + )} + </div> + + <div className="space-y-1"> + <div className="text-gray-600"> + ✅ 완료: {progressStatus.completedRequired}개 + </div> + <div className="text-gray-600"> + ⏳ 남은 필수: {progressStatus.totalRequired - progressStatus.completedRequired}개 + </div> + </div> + </div> */} + + {/* 상세 진행률 바 */} + {/* <div className="space-y-2"> + <div className="flex justify-between text-xs text-gray-600"> + <span>필수 질문 진행률</span> + <span> + {Math.round(progressStatus.progressPercentage)}% + {hasConditionalQuestions && ( + <span className="ml-1 text-blue-600">(조건부 포함)</span> + )} + </span> + </div> + <div className="w-full bg-gray-200 rounded-full h-3"> + <div + className="bg-gradient-to-r from-blue-500 to-blue-600 h-3 rounded-full transition-all duration-500 ease-out" + style={{ width: `${progressStatus.progressPercentage}%` }} + /> + </div> + </div> */} + + {/* 중요 안내 - 컴팩트하게 */} + <div className="p-3 border rounded-lg bg-yellow-50 border-yellow-200"> + <div className="flex items-start"> + <AlertTriangle className="h-4 w-4 text-yellow-600 mt-0.5 mr-2 flex-shrink-0" /> + <div> + <p className="font-medium text-yellow-800 text-sm">준법 의무 확인 필수</p> + <p className="text-xs text-yellow-700 mt-1"> + 모든 필수 항목을 정확히 작성해주세요. 답변에 따라 추가 질문이 나타날 수 있습니다. + </p> + </div> + </div> + </div> + </div> + )} + + {/* 헤더가 접혀있을 때 보이는 요약 정보 */} + {!isHeaderExpanded && ( + <div className="flex items-center justify-between text-xs text-gray-500 mt-2 pt-2 border-t"> + <span> + 📋 {totalVisibleQuestions}개 질문 + {hasConditionalQuestions && ( + <span className="text-blue-600 ml-1">(+{conditionalQuestionCount}개 조건부)</span> + )} + </span> + <span + className="text-blue-600 hover:underline cursor-pointer" + onClick={() => setIsHeaderExpanded(true)} + > + 상세 정보 보기 ↑ + </span> + </div> + )} +</CardHeader> + + <CardContent className="flex-1 min-h-0 overflow-y-auto"> + <form onSubmit={(e) => e.preventDefault()} className="space-y-6"> + {/* <div className="p-4 border rounded-lg bg-yellow-50"> + <div className="flex items-start"> + <AlertTriangle className="h-5 w-5 text-yellow-600 mt-0.5 mr-2" /> + <div> + <p className="font-medium text-yellow-800">중요 안내</p> + <p className="text-sm text-yellow-700 mt-1"> + 본 설문조사는 준법 의무 확인을 위한 필수 절차입니다. 모든 항목을 정확히 작성해주세요. + {conditionalHandler && ( + <span className="block mt-1"> + ⚡ 답변에 따라 추가 질문이 나타날 수 있으며, 이 경우 모든 추가 질문도 완료해야 합니다. + </span> + )} + </p> + </div> + </div> + </div> */} + + <div className="space-y-4"> + {visibleQuestions.map((question: any) => { + const fieldName = `${question.id}`; + const isComplete = progressStatus.completedQuestionIds.includes(question.id); + const isConditional = !!question.parentQuestionId; + + return ( + <div + key={question.id} + id={`question-${question.id}`} + className={`mb-6 p-4 border rounded-lg transition-colors duration-200 ${ + isConditional + ? 'bg-blue-50 border-blue-200' + : 'bg-gray-50 border-gray-200' + } ${!isComplete && question.isRequired ? 'ring-2 ring-red-100' : ''}`} + > + <div className="flex items-start justify-between mb-3"> + <div className="flex-1"> + <Label className="text-sm font-medium text-gray-900 flex items-center flex-wrap gap-2"> + <span className="px-2 py-1 bg-blue-100 text-blue-700 rounded text-xs"> + Q{question.questionNumber} + </span> + + {isConditional && ( + <span className="px-2 py-1 bg-amber-100 text-amber-700 rounded text-xs"> + ⚡ 조건부 질문 + </span> + )} + + {question.questionType === 'FILE' && ( + <span className="px-2 py-1 bg-green-100 text-green-700 rounded text-xs"> + 📎 파일 업로드 + </span> + )} + + <div className="w-full mt-1"> + {question.questionText} + {question.isRequired && <span className="text-red-500 ml-1">*</span>} + </div> + </Label> + </div> + + {isComplete && ( + <CheckCircle2 className="h-5 w-5 text-green-500 ml-2 flex-shrink-0" /> + )} + </div> + + {/* 질문 타입별 렌더링 */} + {question.questionType === 'RADIO' && ( + <Controller + name={`${fieldName}.answerValue`} + control={control} + rules={{ required: question.isRequired ? '필수 항목입니다.' : false }} + render={({ field }) => ( + <RadioGroup + value={field.value || ''} + onValueChange={(value) => { + field.onChange(value); + handleAnswerChange(question.id, 'answerValue', value); + }} + className="space-y-2" + > + {question.options?.map((option: any) => ( + <div key={option.id} className="flex items-center space-x-2"> + <RadioGroupItem value={option.optionValue} id={`${question.id}-${option.id}`} /> + <Label htmlFor={`${question.id}-${option.id}`} className="text-sm"> + {option.optionText} + </Label> + </div> + ))} + </RadioGroup> + )} + /> + )} + + {question.questionType === 'DROPDOWN' && ( + <div className="space-y-2"> + <Controller + name={`${fieldName}.answerValue`} + control={control} + rules={{ required: question.isRequired ? '필수 항목입니다.' : false }} + render={({ field }) => ( + <Select + value={field.value || ''} + onValueChange={(value) => { + field.onChange(value); + handleAnswerChange(question.id, 'answerValue', value); + }} + > + <SelectTrigger> + <SelectValue placeholder="선택해주세요" /> + </SelectTrigger> + <SelectContent> + {question.options?.map((option: any) => ( + <SelectItem key={option.id} value={option.optionValue}> + {option.optionText} + </SelectItem> + ))} + </SelectContent> + </Select> + )} + /> + <OtherTextInput questionId={question.id} fieldName={fieldName} /> + </div> + )} + + {question.questionType === 'TEXTAREA' && ( + <Controller + name={`${fieldName}.detailText`} + control={control} + rules={{ required: question.isRequired ? '필수 항목입니다.' : false }} + render={({ field }) => ( + <Textarea + {...field} + placeholder="상세한 내용을 입력해주세요" + rows={4} + /> + )} + /> + )} + + {question.hasDetailText && ( + <div className="mt-3"> + <Label className="text-sm text-gray-700 mb-2 block">상세 내용을 기술해주세요:</Label> + <Controller + name={`${fieldName}.detailText`} + control={control} + rules={{ required: question.isRequired ? '상세 내용을 입력해주세요.' : false }} + render={({ field }) => ( + <Textarea + {...field} + placeholder="상세한 내용을 입력해주세요" + rows={3} + className="w-full" + /> + )} + /> + </div> + )} + + {(question.hasFileUpload || question.questionType === 'FILE') && ( + <div className="mt-3"> + <Label className="text-sm text-gray-700 mb-2 block">첨부파일:</Label> + <div className="border-2 border-dashed border-gray-300 rounded-lg p-4"> + <input + type="file" + multiple + onChange={(e) => handleFileUpload(question.id, e.target.files)} + className="hidden" + id={`file-${question.id}`} + /> + <label htmlFor={`file-${question.id}`} className="cursor-pointer"> + <div className="flex flex-col items-center"> + <Upload className="h-8 w-8 text-gray-400 mb-2" /> + <span className="text-sm text-gray-500">파일을 선택하거나 여기에 드래그하세요</span> + </div> + </label> + + {uploadedFiles[question.id] && uploadedFiles[question.id].length > 0 && ( + <div className="mt-3 space-y-1"> + {uploadedFiles[question.id].map((file, index) => ( + <div key={index} className="flex items-center space-x-2 text-sm"> + <FileText className="h-4 w-4 text-blue-500" /> + <span>{file.fileName}</span> + <span className="text-gray-500">({(file.fileSize / 1024).toFixed(1)} KB)</span> + </div> + ))} + </div> + )} + </div> + </div> + )} + + {errors[fieldName] && ( + <p className="mt-2 text-sm text-red-600 flex items-center"> + <AlertTriangle className="h-4 w-4 mr-1" /> + {errors[fieldName]?.answerValue?.message || + errors[fieldName]?.detailText?.message || + '필수 항목을 완료해주세요.'} + </p> + )} + </div> + ); + })} + </div> + + <div className="flex justify-end pt-6 border-t"> + <div className="flex items-center space-x-4"> + <div className="text-sm"> + {canComplete ? ( + <div className="text-green-600 font-medium flex items-center"> + <CheckCircle2 className="h-4 w-4 mr-1" /> + 모든 필수 항목 완료됨 + {hasConditionalQuestions && ( + <span className="ml-2 text-xs text-blue-600"> + (조건부 {conditionalQuestionCount}개 포함) + </span> + )} + </div> + ) : ( + <div className="space-y-1"> + <div className="flex items-center text-gray-600"> + <AlertTriangle className="h-4 w-4 mr-1 text-red-500" /> + {progressStatus.completedRequired}/{progressStatus.totalRequired} 완료 + </div> + {hasConditionalQuestions && ( + <div className="text-xs text-blue-600"> + 기본 + 조건부 {conditionalQuestionCount}개 포함 + </div> + )} + </div> + )} + </div> + + <Button + type="button" + onClick={handleSurveyComplete} + disabled={!canComplete || isSubmitting} + className={`transition-all duration-200 ${ + canComplete && !isSubmitting + ? 'bg-green-600 hover:bg-green-700 shadow-lg' + : 'bg-gray-400 cursor-not-allowed' + }`} + > + {isSubmitting ? ( + <> + <Loader2 className="h-4 w-4 mr-2 animate-spin" /> + 저장 중... + </> + ) : ( + <> + <CheckCircle2 className="h-4 w-4 mr-2" /> + 설문조사 완료 + </> + )} + <span className="ml-1 text-xs"> + ({progressStatus.completedRequired}/{progressStatus.totalRequired}) + </span> + </Button> + </div> + </div> + </form> + </CardContent> + </Card> + </div> + ); +};
\ No newline at end of file diff --git a/lib/basic-contract/viewer/basic-contract-sign-viewer.tsx b/lib/basic-contract/viewer/basic-contract-sign-viewer.tsx index fbf36738..943878da 100644 --- a/lib/basic-contract/viewer/basic-contract-sign-viewer.tsx +++ b/lib/basic-contract/viewer/basic-contract-sign-viewer.tsx @@ -6,16 +6,13 @@ import React, { useRef, SetStateAction, Dispatch, - useMemo, - useCallback, } from "react"; import { WebViewerInstance } from "@pdftron/webviewer"; -import { Loader2, FileText, ClipboardList, AlertTriangle, FileSignature, Target, CheckCircle2 } from "lucide-react"; +import { Loader2, FileText, ClipboardList, AlertTriangle, FileSignature, Target, CheckCircle2, BookOpen } from "lucide-react"; import { toast } from "sonner"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; 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, @@ -24,20 +21,15 @@ import { DialogDescription, DialogFooter, } from "@/components/ui/dialog"; -import { Label } from "@/components/ui/label"; -import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group"; -import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; -import { Input } from "@/components/ui/input"; -import { Textarea } from "@/components/ui/textarea"; -import { 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"; +import { getActiveSurveyTemplate, getVendorSignatureFile, type SurveyTemplateWithQuestions } from '../service'; +import { useConditionalSurvey } from '../vendor-table/survey-conditional'; +import { SurveyComponent } from './SurveyComponent'; +import { GtcClausesComponent } from './GtcClausesComponent'; interface FileInfo { path: string; name: string; - type: 'main' | 'attachment' | 'survey'; + type: 'main' | 'attachment' | 'survey' | 'clauses'; } interface BasicContractSignViewerProps { @@ -50,21 +42,13 @@ interface BasicContractSignViewerProps { onSign?: (documentData: ArrayBuffer, surveyData?: any) => Promise<void>; instance: WebViewerInstance | null; setInstance: Dispatch<SetStateAction<WebViewerInstance | null>>; - onSurveyComplete?: () => void; // 🔥 새로 추가 - onSignatureComplete?: () => void; // 🔥 새로 추가 + onSurveyComplete?: () => void; + onSignatureComplete?: () => void; + onGtcCommentStatusChange?: (hasComments: boolean, commentCount: number) => void; + mode?: 'vendor' | 'buyer'; // 추가된 mode prop t?: (key: string) => string; } -// 폼 데이터 타입 정의 -interface SurveyFormData { - [key: string]: { - answerValue?: string; - detailText?: string; - otherText?: string; - files?: File[]; - }; -} - // 자동 서명 필드 생성을 위한 타입 정의 interface SignaturePattern { regex: RegExp; @@ -80,9 +64,11 @@ interface SignaturePattern { class AutoSignatureFieldDetector { private instance: WebViewerInstance; private signaturePatterns: SignaturePattern[]; + private mode: 'vendor' | 'buyer'; // mode 추가 - constructor(instance: WebViewerInstance) { + constructor(instance: WebViewerInstance, mode: 'vendor' | 'buyer' = 'vendor') { this.instance = instance; + this.mode = mode; this.signaturePatterns = this.initializePatterns(); } @@ -128,7 +114,7 @@ class AutoSignatureFieldDetector { } async detectAndCreateSignatureFields(): Promise<string[]> { - console.log("🔍 안전한 서명 필드 감지 시작..."); + console.log(`🔍 안전한 서명 필드 감지 시작... (모드: ${this.mode})`); try { if (!this.instance?.Core?.documentViewer) { @@ -166,77 +152,87 @@ class AutoSignatureFieldDetector { } private async createSimpleSignatureField(): Promise<string> { - try { - const { Core } = this.instance; - const { documentViewer, annotationManager, Annotations } = Core; + 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 page = documentViewer.getPageCount(); + const w = documentViewer.getPageWidth(page) || 612; + const h = documentViewer.getPageHeight(page) || 792; - console.log(`📏 페이지 정보: ${pageCount}페이지, 크기 ${pageWidth}x${pageHeight}`); - - const fieldName = `simple_signature_${Date.now()}`; - const flags = new Annotations.WidgetFlags(); - - const formField = new Core.Annotations.Forms.Field( - `SignatureFormField`, - { - type: "Sig", - flags, - } - ); - - const signatureWidget = new Annotations.SignatureWidgetAnnotation(formField, { - Width: 150, - Height: 50 - }); + const fieldName = `simple_signature_${Date.now()}`; + const flags = new Annotations.WidgetFlags(); + flags.set('Required', true); - signatureWidget.setPageNumber(pageCount); - signatureWidget.setX(pageWidth * 0.7); - signatureWidget.setY(pageHeight * 0.85); - signatureWidget.setWidth(150); - signatureWidget.setHeight(50); + const field = new Core.Annotations.Forms.Field(fieldName, { type: 'Sig', flags }); - annotationManager.addAnnotation(signatureWidget); - annotationManager.redrawAnnotation(signatureWidget); + const widget = new Annotations.SignatureWidgetAnnotation(field, { Width: 150, Height: 50 }); + widget.setPageNumber(page); + + // 구매자 모드일 때는 왼쪽 하단으로 위치 설정 + if (this.mode === 'buyer') { + widget.setX(w * 0.1); // 왼쪽 (10%) + widget.setY(h * 0.85); // 하단 (85%) + } else { + // 협력업체 모드일 때는 기존처럼 오른쪽 + widget.setX(w * 0.7); // 오른쪽 (70%) + widget.setY(h * 0.85); // 하단 (85%) + } + + widget.setWidth(150); + widget.setHeight(50); - console.log(`✅ 서명 필드 생성: ${fieldName}`); - return fieldName; + const fm = annotationManager.getFieldManager(); + fm.addField(field); + annotationManager.addAnnotation(widget); + annotationManager.drawAnnotationsFromList([widget]); - } catch (error) { - console.error("📛 서명 필드 생성 실패:", error); - return "manual_signature_required"; - } + return fieldName; } } -function useAutoSignatureFields(instance: WebViewerInstance | null) { +function useAutoSignatureFields(instance: WebViewerInstance | null, mode: 'vendor' | 'buyer' = 'vendor') { 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); + const processedDocumentRef = useRef<string | null>(null); // 처리된 문서 추적 + const handlerRef = useRef<(() => void) | null>(null); // 핸들러 참조 저장 useEffect(() => { if (!instance) return; const { documentViewer } = instance.Core; + // 새로운 핸들러 생성 (참조가 변하지 않도록) const handleDocumentLoaded = () => { + // 현재 문서의 고유 식별자 생성 + const currentDoc = documentViewer.getDocument(); + const documentId = currentDoc ? `${currentDoc.getFilename()}_${Date.now()}` : null; + + // 같은 문서를 이미 처리했다면 스킵 + if (documentId && processedDocumentRef.current === documentId) { + console.log("📛 이미 처리된 문서이므로 스킵:", documentId); + return; + } + if (processingRef.current) { console.log("📛 이미 처리 중이므로 스킵"); return; } + // 이전 타이머 클리어 if (timeoutRef.current) { clearTimeout(timeoutRef.current); timeoutRef.current = null; } timeoutRef.current = setTimeout(async () => { + // 다시 한번 중복 체크 if (processingRef.current) return; + if (documentId && processedDocumentRef.current === documentId) return; processingRef.current = true; setIsProcessing(true); @@ -249,32 +245,58 @@ function useAutoSignatureFields(instance: WebViewerInstance | null) { throw new Error("문서가 준비되지 않았습니다."); } - const detector = new AutoSignatureFieldDetector(instance); + // 기존 서명 필드 확인 + const { annotationManager } = instance.Core; + const existingAnnotations = annotationManager.getAnnotationsList(); + const existingSignatureFields = existingAnnotations.filter( + annot => annot instanceof instance.Core.Annotations.SignatureWidgetAnnotation + ); + + // 이미 서명 필드가 있으면 생성하지 않음 + if (existingSignatureFields.length > 0) { + console.log("📋 기존 서명 필드 발견:", existingSignatureFields.length); + const fieldNames = existingSignatureFields.map((field, idx) => + field.getField()?.name || `existing_signature_${idx}` + ); + setSignatureFields(fieldNames); + + // 처리 완료 표시 + if (documentId) { + processedDocumentRef.current = documentId; + } + + toast.success(`📋 ${fieldNames.length}개의 기존 서명 필드를 확인했습니다.`); + return; + } + + const detector = new AutoSignatureFieldDetector(instance, mode); // mode 전달 const fields = await detector.detectAndCreateSignatureFields(); setSignatureFields(fields); + // 처리 완료 표시 + if (documentId) { + processedDocumentRef.current = documentId; + } + if (fields.length > 0) { const hasSimpleField = fields.some(field => field.startsWith('simple_signature_')); if (hasSimpleField) { + const positionMessage = mode === 'buyer' + ? "마지막 페이지 왼쪽 하단의 파란색 영역에서 서명해주세요." + : "마지막 페이지 하단의 파란색 영역에서 서명해주세요."; + toast.success("📝 서명 필드가 생성되었습니다.", { - description: "마지막 페이지 하단의 파란색 영역에서 서명해주세요.", + description: positionMessage, icon: <FileSignature className="h-4 w-4 text-blue-500" />, duration: 5000 }); - } else { - toast.success(`📋 ${fields.length}개의 서명 필드를 확인했습니다.`, { - description: "기존 서명 필드가 발견되었습니다.", - icon: <CheckCircle2 className="h-4 w-4 text-green-500" />, - duration: 4000 - }); } } } catch (error) { console.error("📛 서명 필드 처리 실패:", error); - const errorMessage = error instanceof Error ? error.message : "서명 필드 처리에 실패했습니다."; setError(errorMessage); @@ -289,27 +311,53 @@ function useAutoSignatureFields(instance: WebViewerInstance | null) { }, 3000); }; - documentViewer.removeEventListener('documentLoaded', handleDocumentLoaded); + // 핸들러 참조 저장 + handlerRef.current = handleDocumentLoaded; + + // 이전 리스너 제거 (저장된 참조 사용) + if (handlerRef.current) { + documentViewer.removeEventListener('documentLoaded', handlerRef.current); + } + + // 새 리스너 등록 documentViewer.addEventListener('documentLoaded', handleDocumentLoaded); + // 이미 문서가 로드되어 있다면 즉시 실행 + if (documentViewer.getDocument()) { + // 짧은 지연 후 실행 (WebViewer 초기화 완료 보장) + setTimeout(() => { + if (!processingRef.current) { + handleDocumentLoaded(); + } + }, 1000); + } + return () => { - documentViewer.removeEventListener('documentLoaded', handleDocumentLoaded); + // 클리어 시 저장된 참조 사용 + if (handlerRef.current) { + documentViewer.removeEventListener('documentLoaded', handlerRef.current); + } if (timeoutRef.current) { clearTimeout(timeoutRef.current); timeoutRef.current = null; } + // 상태 리셋 processingRef.current = false; + processedDocumentRef.current = null; + handlerRef.current = null; }; - }, [instance]); + }, [instance, mode]); // mode 의존성 추가 + // 컴포넌트 언마운트 시 정리 useEffect(() => { return () => { if (timeoutRef.current) { clearTimeout(timeoutRef.current); } processingRef.current = false; + processedDocumentRef.current = null; }; }, []); @@ -320,198 +368,121 @@ function useAutoSignatureFields(instance: WebViewerInstance | null) { error }; } - -// 🔥 서명 감지를 위한 커스텀 훅 수정 +// XFDF 기반 서명 감지 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); + const onCompleteRef = useRef(onSignatureComplete); - // 콜백 레퍼런스 업데이트 useEffect(() => { - onSignatureCompleteRef.current = onSignatureComplete; + onCompleteRef.current = onSignatureComplete }, [onSignatureComplete]); - const checkSignatureFields = useCallback(async () => { - if (!instance?.Core?.annotationManager) { - console.log('🔍 서명 체크: annotationManager 없음'); - return false; - } - - try { - const { annotationManager, documentViewer } = instance.Core; - - // 문서가 로드되지 않았으면 false 반환 - if (!documentViewer.getDocument()) { - console.log('🔍 서명 체크: 문서 미로드'); - return false; - } + useEffect(() => { + if (!instance?.Core) return; - let hasSignature = false; + const { annotationManager, documentViewer } = instance.Core; + const checkSignedByAppearance = () => { + try { + const annotations = annotationManager.getAnnotationsList(); + const signatureWidgets = annotations.filter( + annot => annot instanceof instance.Core.Annotations.SignatureWidgetAnnotation + ); - // 1. Form Fields 확인 (더 정확한 방법) - const fieldManager = annotationManager.getFieldManager(); - const fields = fieldManager.getFields(); - - 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; - } + if (signatureWidgets.length === 0) { + return false; } - } - // 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; - } + for (const widget of signatureWidgets) { + const isSignedByAppearance = widget.isSignedByAppearance(); + + if (isSignedByAppearance) { + return true; } } + return false; + + } catch (error) { + console.error('서명 위젯 확인 중 오류:', error); + return false; } + }; - // 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; - } + const checkSigned = async () => { + try { + const hasSignature = await checkSignedByAppearance(); + + if (hasSignature !== hasValidSignature) { + setHasValidSignature(hasSignature); + + if (hasSignature && onCompleteRef.current) { + onCompleteRef.current(); } } + + } catch (error) { + console.error('서명 확인 중 오류:', error); } + }; - console.log('🔍 최종 서명 감지 결과:', { - hasSignature, - fieldsCount: fields.length, - annotationsCount: annotationManager.getAnnotationsList().length - }); - - return hasSignature; - } catch (error) { - console.error('📛 서명 확인 중 에러:', error); - return false; - } - }, [instance]); + const onAnnotationChanged = (annotations: any[], action: string) => { + console.log("서명 변경") + // if (action === 'delete') return; - // 실시간 서명 감지 (무한 렌더링 방지) - useEffect(() => { - if (!instance?.Core) return; + setTimeout(checkSigned, 800); + }; - 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 onDocumentLoaded = () => { + setTimeout(checkSigned, 2000); }; - // 문서 로드 후 모니터링 시작 - const { documentViewer } = instance.Core; - - const handleDocumentLoaded = () => { - console.log('📄 문서 로드 완료, 서명 모니터링 준비'); - // 문서 로드 후 3초 뒤에 모니터링 시작 (안정성 확보) - setTimeout(startMonitoring, 3000); + const onPageUpdated = () => { + setTimeout(checkSigned, 1000); }; - if (documentViewer?.getDocument()) { - // 이미 문서가 로드되어 있다면 바로 시작 - setTimeout(startMonitoring, 1000); - } else { - // 문서 로드 대기 - documentViewer?.addEventListener('documentLoaded', handleDocumentLoaded); + const onAnnotationSelected = () => { + setTimeout(checkSigned, 500); + }; + + const onAnnotationUnselected = () => { + setTimeout(checkSigned, 1000); + }; + + try { + annotationManager.addEventListener('annotationChanged', onAnnotationChanged); + annotationManager.addEventListener('annotationSelected', onAnnotationSelected); + annotationManager.addEventListener('annotationUnselected', onAnnotationUnselected); + documentViewer.addEventListener('documentLoaded', onDocumentLoaded); + documentViewer.addEventListener('pageNumberUpdated', onPageUpdated); + + } catch (error) { + console.error('이벤트 리스너 등록 실패:', error); } - // 클리너 함수 + if (documentViewer.getDocument()) { + setTimeout(checkSigned, 1000); + } + + const pollInterval = setInterval(() => { + checkSigned(); + }, 5000); + return () => { - console.log('🧹 서명 모니터링 정리'); - if (checkIntervalRef.current) { - clearInterval(checkIntervalRef.current); - checkIntervalRef.current = null; + try { + annotationManager.removeEventListener('annotationChanged', onAnnotationChanged); + annotationManager.removeEventListener('annotationSelected', onAnnotationSelected); + annotationManager.removeEventListener('annotationUnselected', onAnnotationUnselected); + documentViewer.removeEventListener('documentLoaded', onDocumentLoaded); + documentViewer.removeEventListener('pageNumberUpdated', onPageUpdated); + clearInterval(pollInterval); + } catch (error) { + console.error('클린업 중 오류:', error); } - documentViewer?.removeEventListener('documentLoaded', handleDocumentLoaded); }; - }, [instance]); // onSignatureComplete 제거하여 무한 렌더링 방지 + }, [instance, hasValidSignature]); - // 수동 서명 확인 함수 - const manualCheckSignature = useCallback(async () => { - console.log('🔍 수동 서명 확인 요청'); - const hasSignature = await checkSignatureFields(); - setHasValidSignature(hasSignature); - lastSignatureStateRef.current = hasSignature; - return hasSignature; - }, [checkSignatureFields]); - - return { - hasValidSignature, - checkSignature: manualCheckSignature - }; + return { hasValidSignature }; } export function BasicContractSignViewer({ @@ -524,8 +495,10 @@ export function BasicContractSignViewer({ onSign, instance, setInstance, - onSurveyComplete, // 🔥 추가 - onSignatureComplete, // 🔥 추가 + onSurveyComplete, + onSignatureComplete, + onGtcCommentStatusChange, + mode = 'vendor', // 기본값 vendor t = (key: string) => key, }: BasicContractSignViewerProps) { @@ -535,7 +508,9 @@ export function BasicContractSignViewer({ 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 [gtcCommentStatus, setGtcCommentStatus] = useState<{ hasComments: boolean; commentCount: number }>({ hasComments: false, commentCount: 0 }); + + console.log(surveyTemplate, "surveyTemplate") const conditionalHandler = useConditionalSurvey(surveyTemplate); @@ -546,13 +521,15 @@ export function BasicContractSignViewer({ const [showDialog, setShowDialog] = useState(isOpen); const webViewerInstance = useRef<WebViewerInstance | null>(null); - const { signatureFields, isProcessing: isAutoSignProcessing, hasSignatureFields, error: autoSignError } = useAutoSignatureFields(webViewerInstance.current || instance); - - // 🔥 서명 감지 훅 사용 + // mode 전달 + const { signatureFields, isProcessing: isAutoSignProcessing, hasSignatureFields, error: autoSignError } = useAutoSignatureFields(webViewerInstance.current || instance, mode); + const { hasValidSignature } = useSignatureDetection(webViewerInstance.current || instance, onSignatureComplete); - const isComplianceTemplate = templateName.includes('준법'); - const isNDATemplate = templateName.includes('비밀유지') || templateName.includes('NDA'); + // 구매자 모드일 때는 템플릿 관련 로직 비활성화 + const isComplianceTemplate = mode === 'buyer' ? false : templateName.includes('준법'); + const isNDATemplate = mode === 'buyer' ? false : (templateName.includes('비밀유지') || templateName.includes('NDA')); + const isGTCTemplate = mode === 'buyer' ? false : templateName.includes('GTC'); const allFiles: FileInfo[] = React.useMemo(() => { const files: FileInfo[] = []; @@ -565,6 +542,11 @@ export function BasicContractSignViewer({ }); } + // 구매자 모드일 때는 추가 파일, 설문조사, 조항 검토 탭 제외 + if (mode === 'buyer') { + return files; // 메인 계약서 파일만 반환 + } + const normalizedAttachments: FileInfo[] = (additionalFiles || []) .map((f: any, idx: number) => ({ path: f.path ?? f.filePath ?? "", @@ -583,8 +565,16 @@ export function BasicContractSignViewer({ }); } + if (isGTCTemplate) { + files.push({ + path: "", + name: "조항 검토", + type: "clauses", + }); + } + return files; - }, [filePath, additionalFiles, templateName, isComplianceTemplate]); + }, [filePath, additionalFiles, templateName, isComplianceTemplate, isGTCTemplate, mode]); const cleanupHtmlStyle = () => { const elements = document.querySelectorAll('.Document_container'); @@ -623,7 +613,8 @@ export function BasicContractSignViewer({ useEffect(() => { setShowDialog(isOpen); - if (isOpen && isComplianceTemplate && !surveyTemplate) { + // 구매자 모드가 아닐 때만 설문조사 템플릿 로드 + if (isOpen && isComplianceTemplate && !surveyTemplate && mode !== 'buyer') { loadSurveyTemplate(); } @@ -631,7 +622,7 @@ export function BasicContractSignViewer({ setIsInitialLoaded(false); currentDocumentPath.current = ""; } - }, [isOpen, isComplianceTemplate]); + }, [isOpen, isComplianceTemplate, mode]); useEffect(() => { if (!filePath) return; @@ -709,7 +700,9 @@ export function BasicContractSignViewer({ setInstance(newInstance); setFileLoading(false); - const { documentViewer } = newInstance.Core; + const { documentViewer, annotationManager, Annotations } = newInstance.Core; + + const { WidgetFlags } = Annotations; const FitMode = newInstance.UI.FitMode; const handleDocumentLoaded = () => { @@ -730,6 +723,30 @@ export function BasicContractSignViewer({ documentViewer.addEventListener('documentLoaded', handleDocumentLoaded); + // 구매자 모드가 아닐 때만 자동 서명 적용 + if (mode !== 'buyer') { + annotationManager.addEventListener('annotationChanged', async (annotList, type) => { + for (const annot of annotList) { + const { fieldName, X, Y, Width, Height, PageNumber } = annot; + + if (type === "add" && annot.Subject === "Widget") { + const signatureImage = await getVendorSignatureFile() + + const stamp = new Annotations.StampAnnotation(); + stamp.PageNumber = PageNumber; + stamp.X = X; + stamp.Y = Y; + stamp.Width = Width; + stamp.Height = Height; + + await stamp.setImageData(signatureImage.data.dataUrl); + annot.sign(stamp); + annot.setFieldFlag(WidgetFlags.READ_ONLY, true); + } + } + }); + } + newInstance.UI.setMinZoomLevel('25%'); newInstance.UI.setMaxZoomLevel('400%'); @@ -789,7 +806,7 @@ export function BasicContractSignViewer({ isCancelled.current = true; cleanupWebViewer(); }; - }, [setInstance]); + }, [setInstance, mode]); const getExtFromPath = (p: string) => { const m = p.toLowerCase().match(/\.([a-z0-9]+)(?:\?.*)?$/); @@ -873,17 +890,21 @@ export function BasicContractSignViewer({ const handleTabChange = async (newTab: string) => { setActiveTab(newTab); - if (newTab === "survey") return; + if (newTab === "survey" || newTab === "clauses") return; const currentInstance = webViewerInstance.current || instance; if (!currentInstance || fileLoading) return; + if (newTab === 'survey' && !surveyTemplate && !surveyLoading && mode !== 'buyer') { + loadSurveyTemplate(); + } + 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]; + targetFile = allFiles.filter(f => f.type !== 'survey' && f.type !== 'clauses')[fileIndex]; } if (!targetFile?.path) { @@ -909,7 +930,7 @@ export function BasicContractSignViewer({ documentViewer.updateView(); window.dispatchEvent(new Event("resize")); } catch (e) { - console.warn("탭 변경 후 레이아웃 새로고침 스킵:", e); + console.warn("탭 변경 후 레이아웃 새고침 스킵:", e); } }); } catch (e) { @@ -965,22 +986,34 @@ export function BasicContractSignViewer({ downloadType: "pdf", }); - if (isComplianceTemplate && !surveyData.completed) { - toast.error("준법 설문조사를 먼저 완료해주세요."); - setActiveTab('survey'); - return; + // 구매자 모드일 때는 설문조사와 GTC 검증 건너뛰기 + if (mode !== 'buyer') { + if (isComplianceTemplate && !surveyData.completed) { + toast.error("준법 설문조사를 먼저 완료해주세요."); + setActiveTab('survey'); + return; + } + + if (isGTCTemplate && gtcCommentStatus.hasComments) { + toast.error("GTC 조항에 코멘트가 있어 서명할 수 없습니다."); + toast.info("모든 코멘트를 삭제하거나 협의를 완료한 후 서명해주세요."); + setActiveTab('clauses'); + return; + } } if (onSign) { await onSign(documentData, { formData, surveyData, signatureFields }); } else { - toast.success("계약서가 성공적으로 서명되었습니다."); + const actionText = mode === 'buyer' ? "최종승인" : "서명"; + toast.success(`계약서가 성공적으로 ${actionText}되었습니다.`); } handleClose(); } catch (error) { - console.error("📛 서명 저장 실패:", error); - toast.error("서명을 저장하는데 실패했습니다."); + console.error(`📛 ${mode === 'buyer' ? '최종승인' : '서명'} 저장 실패:`, error); + const actionText = mode === 'buyer' ? "최종승인" : "서명"; + toast.error(`${actionText}을 저장하는데 실패했습니다.`); } }; @@ -992,842 +1025,7 @@ export function BasicContractSignViewer({ } }; - // 개선된 SurveyComponent - const SurveyComponent = () => { - const { - control, - watch, - setValue, - getValues, - formState: { errors }, - trigger, - } = useForm<SurveyFormData>({ - defaultValues: {}, - mode: 'onChange' - }); - - 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: {} - }; - } - - 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 - }); - } - }); - - 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> - ); - } - - 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="기타 내용을 입력해주세요" - className="mt-2" - /> - )} - /> - ); - }; - - 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> - <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> - - {/* 세부 진행 상황 */} - {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> - </CardHeader> - - <CardContent className="flex-1 min-h-0 overflow-y-auto"> - <form onSubmit={(e) => e.preventDefault()} className="space-y-6"> - <div className="p-4 border rounded-lg bg-yellow-50"> - <div className="flex items-start"> - <AlertTriangle className="h-5 w-5 text-yellow-600 mt-0.5 mr-2" /> - <div> - <p className="font-medium text-yellow-800">중요 안내</p> - <p className="text-sm text-yellow-700 mt-1"> - 본 설문조사는 준법 의무 확인을 위한 필수 절차입니다. 모든 항목을 정확히 작성해주세요. - {conditionalHandler && ( - <span className="block mt-1"> - ⚡ 답변에 따라 추가 질문이 나타날 수 있으며, 이 경우 모든 추가 질문도 완료해야 합니다. - </span> - )} - </p> - </div> - </div> - </div> - - <div className="space-y-4"> - {visibleQuestions.map((question: any) => { - const fieldName = `${question.id}`; - const isComplete = progressStatus.completedQuestionIds.includes(question.id); - const isConditional = !!question.parentQuestionId; - - return ( - <div - key={question.id} - id={`question-${question.id}`} - className={`mb-6 p-4 border rounded-lg transition-colors duration-200 ${isConditional - ? 'bg-blue-50 border-blue-200' - : 'bg-gray-50 border-gray-200' - } ${!isComplete && question.isRequired ? 'ring-2 ring-red-100' : ''}`} - > - <div className="flex items-start justify-between mb-3"> - <div className="flex-1"> - <Label className="text-sm font-medium text-gray-900 flex items-center flex-wrap gap-2"> - <span className="px-2 py-1 bg-blue-100 text-blue-700 rounded text-xs"> - Q{question.questionNumber} - </span> - - {isConditional && ( - <span className="px-2 py-1 bg-amber-100 text-amber-700 rounded text-xs"> - ⚡ 조건부 질문 - </span> - )} - - {question.questionType === 'FILE' && ( - <span className="px-2 py-1 bg-green-100 text-green-700 rounded text-xs"> - 📎 파일 업로드 - </span> - )} - - <div className="w-full mt-1"> - {question.questionText} - {question.isRequired && <span className="text-red-500 ml-1">*</span>} - </div> - </Label> - </div> - - {isComplete && ( - <CheckCircle2 className="h-5 w-5 text-green-500 ml-2 flex-shrink-0" /> - )} - </div> - - {/* 질문 타입별 렌더링 (기존 코드와 동일) */} - {/* 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> - )} - /> - )} - - {/* 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> - )} - - {/* TEXTAREA 타입 */} - {question.questionType === 'TEXTAREA' && ( - <Controller - name={`${fieldName}.detailText`} - control={control} - rules={{ required: question.isRequired ? '필수 항목입니다.' : false }} - render={({ field }) => ( - <Textarea - {...field} - placeholder="상세한 내용을 입력해주세요" - rows={4} - /> - )} - /> - )} - - {/* 상세 텍스트 입력 */} - {question.hasDetailText && ( - <div className="mt-3"> - <Label className="text-sm text-gray-700 mb-2 block">상세 내용을 기술해주세요:</Label> - <Controller - name={`${fieldName}.detailText`} - control={control} - rules={{ required: question.isRequired ? '상세 내용을 입력해주세요.' : false }} - render={({ field }) => ( - <Textarea - {...field} - placeholder="상세한 내용을 입력해주세요" - rows={3} - className="w-full" - /> - )} - /> - </div> - )} - - {/* 파일 업로드 */} - {(question.hasFileUpload || question.questionType === 'FILE') && ( - <div className="mt-3"> - <Label className="text-sm text-gray-700 mb-2 block">첨부파일:</Label> - <div className="border-2 border-dashed border-gray-300 rounded-lg p-4"> - <input - type="file" - multiple - onChange={(e) => handleFileUpload(question.id, e.target.files)} - className="hidden" - id={`file-${question.id}`} - /> - <label htmlFor={`file-${question.id}`} className="cursor-pointer"> - <div className="flex flex-col items-center"> - <Upload className="h-8 w-8 text-gray-400 mb-2" /> - <span className="text-sm text-gray-500">파일을 선택하거나 여기에 드래그하세요</span> - </div> - </label> - - {uploadedFiles[question.id] && uploadedFiles[question.id].length > 0 && ( - <div className="mt-3 space-y-1"> - {uploadedFiles[question.id].map((file, index) => ( - <div key={index} className="flex items-center space-x-2 text-sm"> - <FileText className="h-4 w-4 text-blue-500" /> - <span>{file.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> - ); - })} - </div> - - {/* ✅ 향상된 완료 버튼 */} - <div className="flex justify-end pt-6 border-t"> - <div className="flex items-center space-x-4"> - {/* 진행 상황 요약 */} - <div className="text-sm"> - {canComplete ? ( - <div className="text-green-600 font-medium flex items-center"> - <CheckCircle2 className="h-4 w-4 mr-1" /> - 모든 필수 항목 완료됨 - {hasConditionalQuestions && ( - <span className="ml-2 text-xs text-blue-600"> - (조건부 {conditionalQuestionCount}개 포함) - </span> - )} - </div> - ) : ( - <div className="space-y-1"> - <div className="flex items-center text-gray-600"> - <AlertTriangle className="h-4 w-4 mr-1 text-red-500" /> - {progressStatus.completedRequired}/{progressStatus.totalRequired} 완료 - </div> - {hasConditionalQuestions && ( - <div className="text-xs text-blue-600"> - 기본 + 조건부 {conditionalQuestionCount}개 포함 - </div> - )} - </div> - )} - </div> - - <Button - type="button" - onClick={handleSurveyComplete} - disabled={!canComplete || isSubmitting} - className={`transition-all duration-200 ${canComplete && !isSubmitting - ? 'bg-green-600 hover:bg-green-700 shadow-lg' - : 'bg-gray-400 cursor-not-allowed' - }`} - > - {isSubmitting ? ( - <> - <Loader2 className="h-4 w-4 mr-2 animate-spin" /> - 저장 중... - </> - ) : ( - <> - <CheckCircle2 className="h-4 w-4 mr-2" /> - 설문조사 완료 - </> - )} - <span className="ml-1 text-xs"> - ({progressStatus.completedRequired}/{progressStatus.totalRequired}) - </span> - </Button> - </div> - </div> - </form> - </CardContent> - </Card> - </div> - ); - }; - - // 🔥 서명 상태 표시 컴포넌트 개선 + // 서명 상태 표시 컴포넌트 const SignatureFieldsStatus = () => { if (!hasSignatureFields && !isAutoSignProcessing && !autoSignError && !hasValidSignature) return null; @@ -1846,11 +1044,10 @@ export function BasicContractSignViewer({ ) : 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}개 서명 필드 자동 생성됨 + {signatureFields.length}개 서명 필드 자동 생성됨 {mode === 'buyer' ? '(왼쪽 하단)' : ''} </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" /> @@ -1865,7 +1062,8 @@ export function BasicContractSignViewer({ if (!isOpen && !onClose) { return ( <div className="h-full w-full flex flex-col overflow-hidden"> - {allFiles.length > 1 ? ( + {/* 구매자 모드에서는 탭 없이 단일 뷰어만 표시 */} + {allFiles.length > 1 && mode !== 'buyer' ? ( <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 /> @@ -1876,8 +1074,10 @@ export function BasicContractSignViewer({ tabId = 'main'; } else if (file.type === 'survey') { tabId = 'survey'; + } else if (file.type === 'clauses') { + tabId = 'clauses'; } else { - const fileOnlyIndex = allFiles.slice(0, index).filter(f => f.type !== 'survey').length; + const fileOnlyIndex = allFiles.slice(0, index).filter(f => f.type !== 'survey' && f.type !== 'clauses').length; tabId = `file-${fileOnlyIndex}`; } @@ -1886,6 +1086,8 @@ export function BasicContractSignViewer({ <div className="flex items-center space-x-1"> {file.type === 'survey' ? ( <ClipboardList className="h-3 w-3" /> + ) : file.type === 'clauses' ? ( + <BookOpen className="h-3 w-3" /> ) : ( <FileText className="h-3 w-3" /> )} @@ -1893,6 +1095,11 @@ export function BasicContractSignViewer({ {file.type === 'survey' && surveyData.completed && ( <Badge variant="secondary" className="ml-1 h-4 px-1 text-xs">완료</Badge> )} + {file.type === 'clauses' && gtcCommentStatus.hasComments && ( + <Badge variant="destructive" className="ml-1 h-4 px-1 text-xs"> + 코멘트 {gtcCommentStatus.commentCount} + </Badge> + )} </div> </TabsTrigger> ); @@ -1904,11 +1111,35 @@ export function BasicContractSignViewer({ <div className={`absolute inset-0 p-3 ${activeTab === 'survey' ? 'block' : 'hidden'}`} > - <SurveyComponent /> + {/* 분리된 SurveyComponent 사용 */} + <SurveyComponent + contractId={contractId} + surveyTemplate={surveyTemplate} + surveyLoading={surveyLoading} + conditionalHandler={conditionalHandler} + onSurveyComplete={onSurveyComplete} + onSurveyDataUpdate={setSurveyData} + onLoadSurveyTemplate={loadSurveyTemplate} + setActiveTab={setActiveTab} + /> + </div> + + <div + className={`absolute inset-0 p-3 ${activeTab === 'clauses' ? 'block' : 'hidden'}`} + > + {/* GTC 조항 컴포넌트 */} + <GtcClausesComponent + contractId={contractId} + onCommentStatusChange={(hasComments, commentCount) => { + setGtcCommentStatus({ hasComments, commentCount }); + onGtcCommentStatusChange?.(hasComments, commentCount); + }} + t={t} + /> </div> <div - className={`absolute inset-0 ${activeTab !== 'survey' ? 'block' : 'hidden'}`} + className={`absolute inset-0 ${activeTab !== 'survey' && activeTab !== 'clauses' ? 'block' : 'hidden'}`} > <div className="w-full h-full overflow-auto"> <div @@ -1962,49 +1193,84 @@ export function BasicContractSignViewer({ ); } + const handleGtcCommentStatusChange = React.useCallback((hasComments: boolean, commentCount: number) => { + setGtcCommentStatus({ hasComments, commentCount }); + onGtcCommentStatusChange?.(hasComments, commentCount); + }, [onGtcCommentStatusChange]); + // 다이얼로그 뷰어 렌더링 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> + <span>{mode === 'buyer' ? '구매자 최종승인' : '기본계약서 서명'}</span> <SignatureFieldsStatus /> </DialogTitle> <DialogDescription> - 계약서를 확인하고 서명을 진행해주세요. - {isComplianceTemplate && ( + 계약서를 확인하고 {mode === 'buyer' ? '최종승인을' : '서명을'} 진행해주세요. + {mode !== 'buyer' && isComplianceTemplate && ( <span className="block mt-1 text-amber-600">📋 준법 설문조사를 먼저 완료해주세요.</span> )} + {mode !== 'buyer' && isGTCTemplate && ( + <span className="block mt-1 text-blue-600">📋 GTC 조항을 검토하고 코멘트가 없는지 확인해주세요.</span> + )} {hasSignatureFields && ( <span className="block mt-1 text-green-600"> - 🎯 서명 위치가 자동으로 감지되었습니다. + 🎯 서명 위치가 자동으로 감지되었습니다{mode === 'buyer' ? ' (왼쪽 하단)' : ''}. </span> )} - {/* 🔥 서명 완료 상태 안내 */} {hasValidSignature && ( <span className="block mt-1 text-green-600"> - ✅ 서명이 완료되었습니다. + ✅ {mode === 'buyer' ? '승인이' : '서명이'} 완료되었습니다. + </span> + )} + {mode !== 'buyer' && isGTCTemplate && gtcCommentStatus.hasComments && ( + <span className="block mt-1 text-red-600"> + ⚠️ GTC 조항에 {gtcCommentStatus.commentCount}개의 코멘트가 있어 서명할 수 없습니다. </span> )} </DialogDescription> </DialogHeader> <div className="flex-1 min-h-0 overflow-hidden"> - {allFiles.length > 1 ? ( + {/* 구매자 모드에서는 탭 없이 단일 뷰어만 표시 */} + {allFiles.length > 1 && mode !== 'buyer' ? ( <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}`; + let tabId: string; + if (index === 0) { + tabId = 'main'; + } else if (file.type === 'survey') { + tabId = 'survey'; + } else if (file.type === 'clauses') { + tabId = 'clauses'; + } else { + const fileOnlyIndex = allFiles.slice(0, index).filter(f => f.type !== 'survey' && f.type !== 'clauses').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" /> + ) : file.type === 'clauses' ? ( + <BookOpen 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> )} + {file.type === 'clauses' && gtcCommentStatus.hasComments && ( + <Badge variant="destructive" className="ml-1 h-4 px-1 text-xs"> + 코멘트 {gtcCommentStatus.commentCount} + </Badge> + )} </div> </TabsTrigger> ); @@ -2014,10 +1280,29 @@ export function BasicContractSignViewer({ <div className="flex-1 min-h-0 overflow-hidden relative"> <div className={`absolute inset-0 p-3 ${activeTab === 'survey' ? 'block' : 'hidden'}`}> - <SurveyComponent /> + {/* 분리된 SurveyComponent 사용 */} + <SurveyComponent + contractId={contractId} + surveyTemplate={surveyTemplate} + surveyLoading={surveyLoading} + conditionalHandler={conditionalHandler} + onSurveyComplete={onSurveyComplete} + onSurveyDataUpdate={setSurveyData} + onLoadSurveyTemplate={loadSurveyTemplate} + setActiveTab={setActiveTab} + /> + </div> + + <div className={`absolute inset-0 p-3 ${activeTab === 'clauses' ? 'block' : 'hidden'}`}> + {/* GTC 조항 컴포넌트 */} + <GtcClausesComponent + contractId={contractId} + onCommentStatusChange={handleGtcCommentStatusChange} // 메모이제이션된 콜백 사용 + t={t} + /> </div> - <div className={`absolute inset-0 ${activeTab !== 'survey' ? 'block' : 'hidden'}`}> + <div className={`absolute inset-0 ${activeTab !== 'survey' && activeTab !== 'clauses' ? 'block' : 'hidden'}`}> <div className="w-full h-full overflow-auto"> <div ref={viewer} @@ -2069,7 +1354,7 @@ export function BasicContractSignViewer({ <Button variant="outline" onClick={handleClose} disabled={fileLoading}>취소</Button> <Button onClick={handleSave} disabled={fileLoading || isAutoSignProcessing}> <FileSignature className="h-4 w-4 mr-2" /> - 서명 완료 + {mode === 'buyer' ? '최종승인 완료' : '서명 완료'} </Button> </DialogFooter> </DialogContent> |
