summaryrefslogtreecommitdiff
path: root/lib/basic-contract/viewer/GtcClausesComponent.tsx
diff options
context:
space:
mode:
authordujinkim <dujin.kim@dtsolution.co.kr>2025-08-27 12:06:26 +0000
committerdujinkim <dujin.kim@dtsolution.co.kr>2025-08-27 12:06:26 +0000
commit7548e2ad6948f1c6aa102fcac408bc6c9c0f9796 (patch)
tree8e66703ec821888ad51dcc242a508813a027bf71 /lib/basic-contract/viewer/GtcClausesComponent.tsx
parent7eac558470ef179dad626a8e82db5784fe86a556 (diff)
(대표님, 최겸) 기본계약, 입찰, 파일라우트, 계약서명라우트, 인포메이션, 메뉴설정, PQ(메일템플릿 관련)
Diffstat (limited to 'lib/basic-contract/viewer/GtcClausesComponent.tsx')
-rw-r--r--lib/basic-contract/viewer/GtcClausesComponent.tsx837
1 files changed, 837 insertions, 0 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