From 1a6774d195b5fb9e3547f3268bf3527a8718c9bf Mon Sep 17 00:00:00 2001 From: dujinkim Date: Mon, 17 Nov 2025 08:43:00 +0000 Subject: (임수민) GTC 기본계약 코멘트 수정 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/basic-contract/viewer/clause-review-list.tsx | 475 +++++++++++++++++++++++ 1 file changed, 475 insertions(+) create mode 100644 lib/basic-contract/viewer/clause-review-list.tsx (limited to 'lib/basic-contract/viewer/clause-review-list.tsx') diff --git a/lib/basic-contract/viewer/clause-review-list.tsx b/lib/basic-contract/viewer/clause-review-list.tsx new file mode 100644 index 00000000..9b0c6271 --- /dev/null +++ b/lib/basic-contract/viewer/clause-review-list.tsx @@ -0,0 +1,475 @@ +"use client"; + +import React, { useState, useCallback } from 'react'; +import { ScrollArea } from "@/components/ui/scroll-area"; +import { Card, CardContent } 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 { Label } from "@/components/ui/label"; +import { toast } from "sonner"; +import { + MessageSquare, + Plus, + Trash2, + Paperclip, + X, + Save, + Upload, + FileText, + User, + Building2, + Loader2, +} from "lucide-react"; +import { cn, formatDateTime } from "@/lib/utils"; + +export type ReviewCommentAuthorType = 'SHI' | 'Vendor'; + +export interface ReviewComment { + id: string; + authorType: ReviewCommentAuthorType; + authorName?: string; + comment: string; + attachments?: ReviewAttachment[]; + createdAt: Date; + updatedAt?: Date; +} + +export interface ReviewAttachment { + id: string; + fileName: string; + filePath: string; + fileSize: number; + uploadedAt: Date; +} + +export interface ClauseReviewListProps { + documentId?: number; + comments: ReviewComment[]; + onAddComment?: (comment: Omit) => Promise; + onDeleteComment?: (commentId: string) => Promise; + onUploadAttachment?: (commentId: string, file: File) => Promise; + onDeleteAttachment?: (commentId: string, attachmentId: string) => Promise; + filterAuthorType?: ReviewCommentAuthorType | 'ALL'; + readOnly?: boolean; + currentUserType?: ReviewCommentAuthorType; + className?: string; +} + +export function ClauseReviewList({ + documentId, + comments, + onAddComment, + onDeleteComment, + onUploadAttachment, + onDeleteAttachment, + filterAuthorType = 'ALL', + readOnly = false, + currentUserType = 'Vendor', + className, +}: ClauseReviewListProps) { + const [isAdding, setIsAdding] = useState(false); + const [newComment, setNewComment] = useState(''); + const [newAuthorName, setNewAuthorName] = useState(''); + const [uploadingFiles, setUploadingFiles] = useState>(new Set()); + const [isSaving, setIsSaving] = useState(false); + + // 필터링된 코멘트 + const filteredComments = React.useMemo(() => { + if (filterAuthorType === 'ALL') return comments; + return comments.filter(c => c.authorType === filterAuthorType); + }, [comments, filterAuthorType]); + + // 코멘트 추가 핸들러 + const handleAddComment = useCallback(async () => { + if (!newComment.trim()) { + toast.error("코멘트를 입력해주세요."); + return; + } + + if (!onAddComment) { + toast.error("코멘트 추가 기능이 활성화되지 않았습니다."); + return; + } + + setIsSaving(true); + try { + await onAddComment({ + authorType: currentUserType, + authorName: newAuthorName.trim() || undefined, + comment: newComment.trim(), + attachments: [], + }); + + setNewComment(''); + setNewAuthorName(''); + setIsAdding(false); + toast.success("코멘트가 추가되었습니다."); + } catch (error) { + console.error('코멘트 추가 실패:', error); + toast.error("코멘트 추가에 실패했습니다."); + } finally { + setIsSaving(false); + } + }, [newComment, newAuthorName, currentUserType, onAddComment]); + + // 코멘트 삭제 핸들러 + const handleDeleteComment = useCallback(async (commentId: string) => { + if (!onDeleteComment) return; + + try { + await onDeleteComment(commentId); + toast.success("코멘트가 삭제되었습니다."); + } catch (error) { + console.error('코멘트 삭제 실패:', error); + toast.error("코멘트 삭제에 실패했습니다."); + } + }, [onDeleteComment]); + + // 첨부파일 업로드 핸들러 + const handleUploadAttachment = useCallback(async (commentId: string, file: File) => { + if (!onUploadAttachment) { + toast.error("첨부파일 업로드 기능이 활성화되지 않았습니다."); + return; + } + + setUploadingFiles(prev => new Set(prev).add(commentId)); + try { + await onUploadAttachment(commentId, file); + toast.success(`${file.name}이(가) 업로드되었습니다.`); + } catch (error) { + console.error('파일 업로드 실패:', error); + toast.error("파일 업로드에 실패했습니다."); + } finally { + setUploadingFiles(prev => { + const next = new Set(prev); + next.delete(commentId); + return next; + }); + } + }, [onUploadAttachment]); + + // 첨부파일 삭제 핸들러 + const handleDeleteAttachment = useCallback(async (commentId: string, attachmentId: string) => { + if (!onDeleteAttachment) return; + + try { + await onDeleteAttachment(commentId, attachmentId); + toast.success("첨부파일이 삭제되었습니다."); + } catch (error) { + console.error('첨부파일 삭제 실패:', error); + toast.error("첨부파일 삭제에 실패했습니다."); + } + }, [onDeleteAttachment]); + + // 파일 크기 포맷팅 + const formatFileSize = (bytes: number): string => { + if (bytes < 1024) return bytes + ' B'; + if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB'; + return (bytes / (1024 * 1024)).toFixed(1) + ' MB'; + }; + + return ( +
+ {/* 헤더 */} +
+
+
+ +

협의 코멘트

+
+
+ + 총 {filteredComments.length}개 + + {!readOnly && ( + + )} +
+
+ + {/* 안내 메시지 */} +

+ {filterAuthorType === 'SHI' + ? 'SHI(삼성중공업) 코멘트만 표시됩니다.' + : filterAuthorType === 'Vendor' + ? '협력업체 코멘트만 표시됩니다.' + : '모든 코멘트를 표시합니다.'} +

+
+ + {/* 코멘트 리스트 */} + +
+ {/* 새 코멘트 입력 폼 */} + {isAdding && !readOnly && ( + + +
+
+ +
+ + {currentUserType === 'SHI' ? ( + <> + + SHI + + ) : ( + <> + + 협력업체 + + )} + +
+
+ +
+ + setNewAuthorName(e.target.value)} + placeholder="작성자 이름을 입력하세요..." + className="mt-1.5" + /> +
+ +
+ +