From 7548e2ad6948f1c6aa102fcac408bc6c9c0f9796 Mon Sep 17 00:00:00 2001 From: dujinkim Date: Wed, 27 Aug 2025 12:06:26 +0000 Subject: (대표님, 최겸) 기본계약, 입찰, 파일라우트, 계약서명라우트, 인포메이션, 메뉴설정, PQ(메일템플릿 관련) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/basic-contract/viewer/GtcClausesComponent.tsx | 837 +++++++++++ lib/basic-contract/viewer/SurveyComponent.tsx | 922 ++++++++++++ .../viewer/basic-contract-sign-viewer.tsx | 1515 ++++++-------------- 3 files changed, 2159 insertions(+), 1115 deletions(-) create mode 100644 lib/basic-contract/viewer/GtcClausesComponent.tsx create mode 100644 lib/basic-contract/viewer/SurveyComponent.tsx (limited to 'lib/basic-contract/viewer') 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(null); + const [clauses, setClauses] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [searchTerm, setSearchTerm] = useState(""); + const [expandedItems, setExpandedItems] = useState>(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 ( +
0 ? 'ml-4' : ''}`}> +
+
+ {/* 확장/축소 버튼 */} + + + {/* 조항 번호 */} + + {clause.effectiveItemNumber} + + + {/* 제목 */} + + {clause.effectiveSubtitle} + + + {/* 상태 표시 */} +
+ {/* {clause.hasComment && ( + + + + )} */} + {clause.isExcluded && ( + + 제외 + + )} +
+
+ + {/* 편집 버튼 */} +
+ {clause.isEditing ? ( +
+ + +
+ ) : ( + + )} +
+
+ + {/* 확장된 내용 */} + {isExpanded && ( +
+ {/* 카테고리 */} + {clause.effectiveCategory && ( +
+ 카테고리: + {clause.effectiveCategory} +
+ )} + + {/* 내용 */} + {clause.effectiveContent && ( +

+ {clause.effectiveContent} +

+ )} + + {/* 코멘트 편집 영역 */} + {clause.isEditing && ( +
+
+ + 협의 코멘트 +
+