From 18954df6565108a469fb1608ea3715dd9bb1b02d Mon Sep 17 00:00:00 2001
From: dujinkim <dujin.kim@dtsolution.co.kr>
Date: Mon, 1 Sep 2025 09:12:09 +0000
Subject: (대표님) 구매 기본계약, gtc 개발
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

---
 lib/basic-contract/viewer/GtcClausesComponent.tsx | 262 +++++++++++++++++-----
 1 file changed, 205 insertions(+), 57 deletions(-)

(limited to 'lib/basic-contract/viewer/GtcClausesComponent.tsx')

diff --git a/lib/basic-contract/viewer/GtcClausesComponent.tsx b/lib/basic-contract/viewer/GtcClausesComponent.tsx
index 8f565971..381e69dc 100644
--- a/lib/basic-contract/viewer/GtcClausesComponent.tsx
+++ b/lib/basic-contract/viewer/GtcClausesComponent.tsx
@@ -1,6 +1,6 @@
 "use client";
 
-import React, { useState, useEffect, useCallback,useRef } from 'react';
+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";
@@ -25,17 +25,23 @@ import {
   Minimize2,
   Maximize2,
 } from "lucide-react";
-import { cn } from "@/lib/utils";
-import { 
-  getVendorGtcData, 
+import { cn, formatDateTime } from "@/lib/utils";
+import {
+  getVendorGtcData,
   updateVendorClause,
   checkVendorClausesCommentStatus,
-  type GtcVendorData 
+  type GtcVendorData
 } from "../service";
+import { useSession } from "next-auth/react"
 
 interface GtcClausesComponentProps {
   contractId?: number;
-  onCommentStatusChange?: (hasComments: boolean, commentCount: number) => void;
+  onCommentStatusChange?: (
+    hasComments: boolean, 
+    commentCount: number, 
+    reviewStatus?: string,
+    isComplete?: boolean
+  ) => void;
   t?: (key: string) => string;
 }
 
@@ -52,26 +58,26 @@ type GtcVendorClause = {
   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;
@@ -82,14 +88,24 @@ interface ClauseState extends GtcVendorClause {
   isEditing?: boolean;
   tempComment?: string;
   isSaving?: boolean;
-  // 고유 식별자를 위한 헬퍼 속성
   uniqueId: number;
+  commentHistory?: CommentHistory[]; // 추가
+  showHistory?: boolean; // 이력 표시 여부
 }
 
-export function GtcClausesComponent({ 
-  contractId, 
+interface CommentHistory {
+  vendorClauseId: number;
+  comment: string;
+  actorName?: string;
+  actorEmail?: string;
+  createdAt: Date;
+  action: string;
+}
+
+export function GtcClausesComponent({
+  contractId,
   onCommentStatusChange,
-  t = (key: string) => key 
+  t = (key: string) => key
 }: GtcClausesComponentProps) {
   const [gtcData, setGtcData] = useState<GtcVendorData | null>(null);
   const [clauses, setClauses] = useState<ClauseState[]>([]);
@@ -98,6 +114,7 @@ export function GtcClausesComponent({
   const [searchTerm, setSearchTerm] = useState("");
   const [expandedItems, setExpandedItems] = useState<Set<number>>(new Set());
   const [compactMode, setCompactMode] = useState(true); // 컴팩트 모드 상태 추가
+  const { data: session } = useSession();
 
   const onCommentStatusChangeRef = useRef(onCommentStatusChange);
   onCommentStatusChangeRef.current = onCommentStatusChange;
@@ -109,14 +126,14 @@ export function GtcClausesComponent({
       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,
@@ -125,7 +142,7 @@ export function GtcClausesComponent({
         tempComment: clause.negotiationNote || "",
         isSaving: false,
       }));
-      
+
       setClauses(initialClauses);
 
     } catch (err) {
@@ -136,26 +153,33 @@ export function GtcClausesComponent({
     }
   }, [contractId]);
 
-  const lastCommentStatusRef = useRef<{ hasComments: boolean; commentCount: number } | null>(null);
+  const lastCommentStatusRef = useRef<{ hasComments: boolean; commentCount: number , reviewStatus:string} | null>(null);
 
   // 코멘트 상태 변경을 별도 useEffect로 처리
   useEffect(() => {
-    if (clauses.length > 0) {
+    if (clauses.length > 0 && gtcData) {
       const commentCount = clauses.filter(c => c.hasComment).length;
       const hasComments = commentCount > 0;
+      const reviewStatus = gtcData.vendorDocument?.reviewStatus || 'draft';
+      
+      // reviewStatus가 complete이면 코멘트가 있어도 완료된 것으로 처리
+      const isComplete = reviewStatus === 'complete' || reviewStatus === 'approved';
+      
+      const currentStatus = { hasComments, commentCount, reviewStatus, isComplete };
       
-      // Only call callback if status actually changed
-      const currentStatus = { hasComments, commentCount };
       if (!lastCommentStatusRef.current || 
           lastCommentStatusRef.current.hasComments !== hasComments ||
-          lastCommentStatusRef.current.commentCount !== commentCount) {
+          lastCommentStatusRef.current.commentCount !== commentCount ||
+          lastCommentStatusRef.current.reviewStatus !== reviewStatus) {
         
         lastCommentStatusRef.current = currentStatus;
-        onCommentStatusChangeRef.current?.(hasComments, commentCount);
+        // isComplete 정보도 전달
+        onCommentStatusChangeRef.current?.(hasComments, commentCount, reviewStatus, isComplete);
       }
     }
-  }, [clauses]);
-  
+  }, [clauses, gtcData]);
+
+
   useEffect(() => {
     loadGtcData();
   }, [loadGtcData]);
@@ -176,11 +200,11 @@ export function GtcClausesComponent({
   // 계층 구조로 조항 그룹화
   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);
@@ -188,7 +212,7 @@ export function GtcClausesComponent({
           parentKey = parentClause.uniqueId;
         }
       }
-      
+
       if (!grouped[parentKey]) {
         grouped[parentKey] = [];
       }
@@ -223,7 +247,7 @@ export function GtcClausesComponent({
         return {
           ...clause,
           isEditing: !clause.isEditing,
-          tempComment: clause.negotiationNote || "",
+          tempComment:  "",
         };
       }
       return clause;
@@ -240,17 +264,32 @@ export function GtcClausesComponent({
     }));
   }, []);
 
+  // toggleCommentHistory 함수 추가
+  const toggleCommentHistory = useCallback((uniqueId: number) => {
+    setClauses(prev => prev.map(clause => {
+      if (clause.uniqueId === uniqueId) {
+        return { ...clause, showHistory: !clause.showHistory };
+      }
+      return clause;
+    }));
+  }, []);
+
   // 코멘트 저장
   const saveComment = useCallback(async (uniqueId: number) => {
     const clause = clauses.find(c => c.uniqueId === uniqueId);
     if (!clause) return;
 
-    setClauses(prev => prev.map(c => 
+     // 빈 코멘트 체크 - 신규 입력 시에만
+    if (!clause.hasComment && (!clause.tempComment || clause.tempComment.trim() === "")) {
+      toast.error("코멘트를 입력해주세요.");
+      return;
+    }
+
+    setClauses(prev => prev.map(c =>
       c.uniqueId === uniqueId ? { ...c, isSaving: true } : c
     ));
 
     try {
-      // 기본 조항 정보를 그대로 사용하고 코멘트만 처리
       const clauseData = {
         itemNumber: clause.effectiveItemNumber,
         category: clause.effectiveCategory,
@@ -260,22 +299,38 @@ export function GtcClausesComponent({
       };
 
       const result = await updateVendorClause(
-        clause.id, 
+        clause.id,
         clause.vendorClauseId,
         clauseData,
         gtcData?.vendorDocument
       );
-      
+
       if (result.success) {
-        const hasComment = !!(clause.tempComment?.trim());
+
+              if (!session?.user?.id) {
+                toast.error("로그인이 필요합니다.");
+                return;
+              }
+
+        // 새 코멘트를 이력에 추가
+        const newHistory = {
+          vendorClauseId: result.vendorClauseId,
+          comment: clause.tempComment || "",
+          actorName: session.user.name ||"현재 사용자", // 실제로는 세션에서 가져와야 함
+          createdAt: new Date(),
+          action: "commented"
+        };
 
         setClauses(prev => prev.map(c => {
           if (c.uniqueId === uniqueId) {
+            const updatedHistory = [newHistory, ...(c.commentHistory || [])];
             return {
               ...c,
               vendorClauseId: result.vendorClauseId || c.vendorClauseId,
               negotiationNote: clause.tempComment?.trim() || null,
-              hasComment,
+              latestComment: clause.tempComment?.trim() || null,
+              commentHistory: updatedHistory,
+              hasComment: true,
               isEditing: false,
               isSaving: false,
             };
@@ -284,22 +339,20 @@ export function GtcClausesComponent({
         }));
 
         toast.success("코멘트가 저장되었습니다.");
-
       } else {
         toast.error(result.error || "코멘트 저장에 실패했습니다.");
-        setClauses(prev => prev.map(c => 
+        setClauses(prev => prev.map(c =>
           c.uniqueId === uniqueId ? { ...c, isSaving: false } : c
         ));
       }
     } catch (error) {
       console.error('코멘트 저장 실패:', error);
       toast.error("코멘트 저장 중 오류가 발생했습니다.");
-      setClauses(prev => prev.map(c => 
+      setClauses(prev => prev.map(c =>
         c.uniqueId === uniqueId ? { ...c, isSaving: false } : c
       ));
     }
   }, [clauses, gtcData]);
-
   // 편집 취소
   const cancelEdit = useCallback((uniqueId: number) => {
     setClauses(prev => prev.map(clause => {
@@ -319,7 +372,7 @@ export function GtcClausesComponent({
     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(
@@ -401,8 +454,8 @@ export function GtcClausesComponent({
                 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" 
+                  clause.hasComment
+                    ? "text-amber-600 hover:text-amber-700 hover:bg-amber-50"
                     : "text-gray-500 hover:text-gray-700 hover:bg-gray-50"
                 )}
               >
@@ -426,7 +479,7 @@ export function GtcClausesComponent({
                 <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">
@@ -455,17 +508,72 @@ export function GtcClausesComponent({
             )}
 
             {/* 기존 코멘트 표시 */}
-            {!clause.isEditing && clause.hasComment && clause.negotiationNote && (
+            {!clause.isEditing && clause.hasComment && (
               <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 className="flex items-center justify-between mb-2">
+                  <div className="flex items-center text-sm font-medium text-amber-800">
+                    <MessageSquare className="h-4 w-4 mr-2" />
+                    협의 코멘트
+                    {clause.commentHistory && clause.commentHistory.length > 1 && (
+                      <Badge variant="outline" className="ml-2 text-xs">
+                        {clause.commentHistory.length}개 이력
+                      </Badge>
+                    )}
+                  </div>
+                  {clause.commentHistory && clause.commentHistory.length > 1 && (
+                    <Button
+                      variant="ghost"
+                      size="sm"
+                      onClick={() => toggleCommentHistory(clause.uniqueId)}
+                      className="h-6 px-2 text-xs text-amber-600 hover:text-amber-700"
+                    >
+                      {clause.showHistory ? "이력 숨기기" : "이력 보기"}
+                    </Button>
+                  )}
+                </div>
+
+                {/* 최신 코멘트 */}
+                <div className="space-y-2">
+                  <div className="bg-white p-2 rounded border border-amber-100">
+                    <p className="text-sm text-amber-700 whitespace-pre-wrap">
+                      {clause.latestComment || clause.negotiationNote}
+                    </p>
+                    {clause.commentHistory?.[0] && (
+                      <div className="flex items-center justify-between mt-1 pt-1 border-t border-amber-100">
+                        <span className="text-xs text-amber-600">
+                          {clause.commentHistory[0].actorName || "SHI"}
+                        </span>
+                        <span className="text-xs text-amber-500">
+                          {formatDateTime(clause.commentHistory[0].createdAt, "KR")}
+                        </span>
+                      </div>
+                    )}
+                  </div>
+
+                  {/* 이전 코멘트 이력 */}
+                  {clause.showHistory && clause.commentHistory && clause.commentHistory.length > 1 && (
+                    <div className="space-y-1.5 max-h-60 overflow-y-auto">
+                      {clause.commentHistory.slice(1).map((history, idx) => (
+                        <div key={idx} className="bg-white/50 p-2 rounded border border-amber-100/50">
+                          <p className="text-xs text-amber-600 whitespace-pre-wrap">
+                            {history.comment}
+                          </p>
+                          <div className="flex items-center justify-between mt-1 pt-1 border-t border-amber-100/50">
+                            <span className="text-xs text-amber-500">
+                              {history.actorName || "SHI"}
+                            </span>
+                            <span className="text-xs text-amber-400">
+                              {formatDateTime(history.createdAt, "KR")}
+                            </span>
+                          </div>
+                        </div>
+                      ))}
+                    </div>
+                  )}
                 </div>
-                <p className="text-sm text-amber-700 whitespace-pre-wrap">
-                  {clause.negotiationNote}
-                </p>
               </div>
             )}
+
           </div>
         )}
 
@@ -484,7 +592,7 @@ export function GtcClausesComponent({
     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(
@@ -564,8 +672,8 @@ export function GtcClausesComponent({
                       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" 
+                        clause.hasComment
+                          ? "text-amber-600 hover:text-amber-700 hover:bg-amber-50"
                           : "text-gray-500 hover:text-gray-700 hover:bg-gray-50"
                       )}
                     >
@@ -722,6 +830,27 @@ export function GtcClausesComponent({
                 compactMode ? "h-4 w-4" : "h-5 w-5"
               )} />
               {gtcData.vendorDocument.name}
+              
+              {/* reviewStatus 배지 추가 */}
+              {gtcData.vendorDocument.reviewStatus && (
+                <Badge 
+                  variant="outline" 
+                  className={cn(
+                    "ml-2",
+                    gtcData.vendorDocument.reviewStatus === 'complete' || gtcData.vendorDocument.reviewStatus === 'approved'
+                      ? "bg-green-50 text-green-700 border-green-200"
+                      : gtcData.vendorDocument.reviewStatus === 'reviewing'
+                      ? "bg-blue-50 text-blue-700 border-blue-200"
+                      : "bg-gray-50 text-gray-700 border-gray-200"
+                  )}
+                >
+                  {gtcData.vendorDocument.reviewStatus === 'complete' ? '협의 완료' :
+                   gtcData.vendorDocument.reviewStatus === 'approved' ? '승인됨' :
+                   gtcData.vendorDocument.reviewStatus === 'reviewing' ? '협의 중' :
+                   gtcData.vendorDocument.reviewStatus === 'draft' ? '초안' :
+                   gtcData.vendorDocument.reviewStatus}
+                </Badge>
+              )}
             </h3>
             {!compactMode && (
               <p className="text-sm text-gray-500 mt-0.5">
@@ -747,7 +876,7 @@ export function GtcClausesComponent({
                 <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"
@@ -788,8 +917,8 @@ export function GtcClausesComponent({
           />
         </div>
 
-        {/* 안내 메시지 */}
-        {totalComments > 0 && (
+        {/* 안내 메시지 수정 - reviewStatus 체크 */}
+        {totalComments > 0 && gtcData.vendorDocument.reviewStatus !== 'complete' && gtcData.vendorDocument.reviewStatus !== 'approved' && (
           <div className={cn(
             "bg-amber-50 rounded border border-amber-200",
             compactMode ? "mt-2 p-2" : "mt-2 p-2"
@@ -811,6 +940,25 @@ export function GtcClausesComponent({
             )}
           </div>
         )}
+        
+        {/* 협의 완료 메시지 */}
+        {totalComments > 0 && (gtcData.vendorDocument.reviewStatus === 'complete' || gtcData.vendorDocument.reviewStatus === 'approved') && (
+          <div className={cn(
+            "bg-green-50 rounded border border-green-200",
+            compactMode ? "mt-2 p-2" : "mt-2 p-2"
+          )}>
+            <div className={cn(
+              "flex items-center text-green-800",
+              compactMode ? "text-sm" : "text-sm"
+            )}>
+              <CheckCircle2 className={cn(
+                "mr-2",
+                compactMode ? "h-4 w-4" : "h-4 w-4"
+              )} />
+              <span className="font-medium">협의가 완료되어 서명 가능합니다.</span>
+            </div>
+          </div>
+        )}
       </div>
 
       {/* 조항 목록 */}
@@ -825,7 +973,7 @@ export function GtcClausesComponent({
             </div>
           ) : (
             <div className={compactMode ? "space-y-0.5" : "space-y-1"}>
-              {(groupedClauses[0] || []).map(clause => 
+              {(groupedClauses[0] || []).map(clause =>
                 compactMode ? renderCompactClause(clause) : renderNormalClause(clause)
               )}
             </div>
-- 
cgit v1.2.3

