"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 && (
협의 코멘트