From 1a6774d195b5fb9e3547f3268bf3527a8718c9bf Mon Sep 17 00:00:00 2001
From: dujinkim <dujin.kim@dtsolution.co.kr>
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

---
 .../agreement-comments/agreement-comment-list.tsx  | 517 +++++++++++++++++++++
 1 file changed, 517 insertions(+)
 create mode 100644 lib/basic-contract/agreement-comments/agreement-comment-list.tsx

(limited to 'lib/basic-contract/agreement-comments/agreement-comment-list.tsx')

diff --git a/lib/basic-contract/agreement-comments/agreement-comment-list.tsx b/lib/basic-contract/agreement-comments/agreement-comment-list.tsx
new file mode 100644
index 00000000..8b9cdbea
--- /dev/null
+++ b/lib/basic-contract/agreement-comments/agreement-comment-list.tsx
@@ -0,0 +1,517 @@
+"use client";
+
+import React, { useState, useCallback, useEffect } 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,
+  Download,
+} from "lucide-react";
+import { cn, formatDateTime } from "@/lib/utils";
+import {
+  getAgreementComments,
+  addAgreementComment,
+  deleteAgreementComment,
+  uploadCommentAttachment,
+  deleteCommentAttachment,
+  type AgreementCommentData,
+  type AgreementCommentAuthorType,
+} from "./actions";
+
+export interface AgreementCommentListProps {
+  basicContractId: number;
+  currentUserType?: AgreementCommentAuthorType;
+  readOnly?: boolean;
+  className?: string;
+  onCommentCountChange?: (count: number) => void;
+}
+
+export function AgreementCommentList({
+  basicContractId,
+  currentUserType = 'Vendor',
+  readOnly = false,
+  className,
+  onCommentCountChange,
+}: AgreementCommentListProps) {
+  const [comments, setComments] = useState<AgreementCommentData[]>([]);
+  const [isLoading, setIsLoading] = useState(true);
+  const [isAdding, setIsAdding] = useState(false);
+  const [newComment, setNewComment] = useState('');
+  const [newAuthorName, setNewAuthorName] = useState('');
+  const [uploadingFiles, setUploadingFiles] = useState<Set<number>>(new Set());
+  const [isSaving, setIsSaving] = useState(false);
+
+  // 코멘트 로드
+  const loadComments = useCallback(async () => {
+    try {
+      setIsLoading(true);
+      const data = await getAgreementComments(basicContractId);
+      setComments(data);
+      onCommentCountChange?.(data.length);
+    } catch (error) {
+      console.error('코멘트 로드 실패:', error);
+      toast.error("코멘트를 불러오는데 실패했습니다.");
+    } finally {
+      setIsLoading(false);
+    }
+  }, [basicContractId]); // onCommentCountChange를 dependency에서 제거
+
+  // 초기 로드
+  useEffect(() => {
+    loadComments();
+  }, [basicContractId]); // loadComments 대신 basicContractId만 의존
+
+  // 코멘트 추가 핸들러
+  const handleAddComment = useCallback(async () => {
+    if (!newComment.trim()) {
+      toast.error("코멘트를 입력해주세요.");
+      return;
+    }
+
+    setIsSaving(true);
+    try {
+      const result = await addAgreementComment({
+        basicContractId,
+        comment: newComment.trim(),
+        authorName: newAuthorName.trim() || undefined,
+      });
+
+      if (result.success) {
+        setNewComment('');
+        setNewAuthorName('');
+        setIsAdding(false);
+        toast.success("코멘트가 추가되었습니다.");
+        await loadComments(); // 목록 새로고침
+      } else {
+        toast.error(result.error || "코멘트 추가에 실패했습니다.");
+      }
+    } catch (error) {
+      console.error('코멘트 추가 실패:', error);
+      toast.error("코멘트 추가에 실패했습니다.");
+    } finally {
+      setIsSaving(false);
+    }
+  }, [newComment, newAuthorName, basicContractId]); // loadComments 제거
+
+  // 코멘트 삭제 핸들러
+  const handleDeleteComment = useCallback(async (commentId: number) => {
+    if (!confirm("이 코멘트를 삭제하시겠습니까?")) {
+      return;
+    }
+
+    try {
+      const result = await deleteAgreementComment(commentId);
+      if (result.success) {
+        toast.success("코멘트가 삭제되었습니다.");
+        await loadComments(); // 목록 새로고침
+      } else {
+        toast.error(result.error || "코멘트 삭제에 실패했습니다.");
+      }
+    } catch (error) {
+      console.error('코멘트 삭제 실패:', error);
+      toast.error("코멘트 삭제에 실패했습니다.");
+    }
+  }, []); // loadComments 제거
+
+  // 첨부파일 업로드 핸들러
+  const handleUploadAttachment = useCallback(async (commentId: number, file: File) => {
+    setUploadingFiles(prev => new Set(prev).add(commentId));
+    try {
+      const result = await uploadCommentAttachment(commentId, file);
+      if (result.success) {
+        toast.success(`${file.name}이(가) 업로드되었습니다.`);
+        await loadComments(); // 목록 새로고침
+      } else {
+        toast.error(result.error || "파일 업로드에 실패했습니다.");
+      }
+    } catch (error) {
+      console.error('파일 업로드 실패:', error);
+      toast.error("파일 업로드에 실패했습니다.");
+    } finally {
+      setUploadingFiles(prev => {
+        const next = new Set(prev);
+        next.delete(commentId);
+        return next;
+      });
+    }
+  }, []); // loadComments 제거
+
+  // 첨부파일 삭제 핸들러
+  const handleDeleteAttachment = useCallback(async (commentId: number, attachmentId: string) => {
+    if (!confirm("이 첨부파일을 삭제하시겠습니까?")) {
+      return;
+    }
+
+    try {
+      const result = await deleteCommentAttachment(commentId, attachmentId);
+      if (result.success) {
+        toast.success("첨부파일이 삭제되었습니다.");
+        await loadComments(); // 목록 새로고침
+      } else {
+        toast.error(result.error || "첨부파일 삭제에 실패했습니다.");
+      }
+    } catch (error) {
+      console.error('첨부파일 삭제 실패:', error);
+      toast.error("첨부파일 삭제에 실패했습니다.");
+    }
+  }, []); // loadComments 제거
+
+  // 파일 크기 포맷팅
+  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';
+  };
+
+  if (isLoading) {
+    return (
+      <div className={cn("h-full flex items-center justify-center", className)}>
+        <Loader2 className="h-8 w-8 animate-spin text-blue-500" />
+      </div>
+    );
+  }
+
+  return (
+    <div className={cn("h-full flex flex-col", className)}>
+      {/* 헤더 */}
+      <div className="flex-shrink-0 border-b bg-gray-50 p-3">
+        <div className="flex items-center justify-between mb-2">
+          <div className="flex items-center space-x-2">
+            <MessageSquare className="h-5 w-5 text-blue-500" />
+            <h3 className="font-semibold text-gray-800">협의 코멘트</h3>
+          </div>
+          <div className="flex items-center space-x-2">
+            <Badge variant="outline" className="bg-blue-50 text-blue-700 border-blue-200 h-8 px-3 text-sm flex items-center">
+              총 {comments.length}개
+            </Badge>
+            {!readOnly && (
+              <Button
+                size="sm"
+                onClick={() => setIsAdding(true)}
+                disabled={isAdding}
+                className="h-8"
+              >
+                <Plus className="h-4 w-4 mr-1" />
+                코멘트 추가
+              </Button>
+            )}
+          </div>
+        </div>
+
+        <p className="text-sm text-gray-600">
+          SHI와 협력업체 간 기본계약서 협의 내용을 작성하고 공유합니다.
+        </p>
+      </div>
+
+      {/* 코멘트 리스트 */}
+      <ScrollArea className="flex-1">
+        <div className="p-3 space-y-3">
+          {/* 새 코멘트 입력 폼 */}
+          {isAdding && !readOnly && (
+            <Card 
+              className={cn(
+                "border-l-4",
+                currentUserType === 'SHI'
+                  ? "border-l-blue-500 border-blue-200 bg-blue-50"
+                  : "border-l-green-500 border-green-200 bg-green-50"
+              )}
+            >
+              <CardContent className="pt-4">
+                <div className="space-y-3">
+                  <div>
+                    <Label className="text-sm font-medium text-gray-700">
+                      작성자 유형
+                    </Label>
+                    <div className="mt-1.5 flex items-center space-x-2">
+                      <Badge 
+                        variant="outline" 
+                        className={cn(
+                          "px-3 py-1 font-semibold",
+                          currentUserType === 'SHI' 
+                            ? "bg-blue-600 text-white border-blue-700"
+                            : "bg-green-600 text-white border-green-700"
+                        )}
+                      >
+                        {currentUserType === 'SHI' ? (
+                          <>
+                            <Building2 className="h-3.5 w-3.5 mr-1.5" />
+                            SHI
+                          </>
+                        ) : (
+                          <>
+                            <User className="h-3.5 w-3.5 mr-1.5" />
+                            Vendor
+                          </>
+                        )}
+                      </Badge>
+                    </div>
+                  </div>
+
+                  <div>
+                    <Label htmlFor="authorName" className="text-sm font-medium text-gray-700">
+                      작성자 이름 (선택사항)
+                    </Label>
+                    <Input
+                      id="authorName"
+                      value={newAuthorName}
+                      onChange={(e) => setNewAuthorName(e.target.value)}
+                      placeholder="작성자 이름을 입력하세요..."
+                      className="mt-1.5"
+                    />
+                  </div>
+
+                  <div>
+                    <Label htmlFor="comment" className="text-sm font-medium text-gray-700">
+                      코멘트 *
+                    </Label>
+                    <Textarea
+                      id="comment"
+                      value={newComment}
+                      onChange={(e) => setNewComment(e.target.value)}
+                      placeholder="협의하고 싶은 내용을 입력하세요..."
+                      className="mt-1.5 min-h-[100px]"
+                    />
+                  </div>
+
+                  <div className="flex items-center justify-end space-x-2">
+                    <Button
+                      variant="outline"
+                      size="sm"
+                      onClick={() => {
+                        setIsAdding(false);
+                        setNewComment('');
+                        setNewAuthorName('');
+                      }}
+                      disabled={isSaving}
+                    >
+                      <X className="h-4 w-4 mr-1" />
+                      취소
+                    </Button>
+                    <Button
+                      size="sm"
+                      onClick={handleAddComment}
+                      disabled={isSaving || !newComment.trim()}
+                    >
+                      {isSaving ? (
+                        <Loader2 className="h-4 w-4 mr-1 animate-spin" />
+                      ) : (
+                        <Save className="h-4 w-4 mr-1" />
+                      )}
+                      저장
+                    </Button>
+                  </div>
+                </div>
+              </CardContent>
+            </Card>
+          )}
+
+          {/* 기존 코멘트 목록 */}
+          {comments.length === 0 && !isAdding ? (
+            <div className="text-center py-8">
+              <MessageSquare className="h-12 w-12 text-gray-300 mx-auto mb-3" />
+              <p className="text-sm text-gray-500">
+                아직 코멘트가 없습니다.
+              </p>
+              {!readOnly && (
+                <Button
+                  variant="outline"
+                  size="sm"
+                  className="mt-3"
+                  onClick={() => setIsAdding(true)}
+                >
+                  <Plus className="h-4 w-4 mr-1" />
+                  첫 번째 코멘트 작성하기
+                </Button>
+              )}
+            </div>
+          ) : (
+            comments.map((comment) => (
+              <Card 
+                key={comment.id} 
+                className={cn(
+                  "transition-all duration-200 hover:shadow-md",
+                  comment.authorType === 'SHI' 
+                    ? "border-l-4 border-l-blue-500 border-blue-200 bg-blue-50/50"
+                    : "border-l-4 border-l-green-500 border-green-200 bg-green-50/50"
+                )}
+              >
+                <CardContent className="pt-4">
+                  <div className="space-y-3">
+                    {/* 헤더: 작성자 정보 */}
+                    <div className="flex items-start justify-between">
+                      <div className="flex items-center space-x-2">
+                        <Badge 
+                          variant="outline" 
+                          className={cn(
+                            "px-3 py-1 font-semibold",
+                            comment.authorType === 'SHI' 
+                              ? "bg-blue-600 text-white border-blue-700"
+                              : "bg-green-600 text-white border-green-700"
+                          )}
+                        >
+                          {comment.authorType === 'SHI' ? (
+                            <>
+                              <Building2 className="h-3.5 w-3.5 mr-1.5" />
+                              SHI
+                            </>
+                          ) : (
+                            <>
+                              <User className="h-3.5 w-3.5 mr-1.5" />
+                              Vendor
+                            </>
+                          )}
+                        </Badge>
+                        {comment.authorName && (
+                          <span className="text-sm font-medium text-gray-700">
+                            {comment.authorName}
+                          </span>
+                        )}
+                      </div>
+
+                      {!readOnly && (
+                        <Button
+                          variant="ghost"
+                          size="sm"
+                          onClick={() => handleDeleteComment(comment.id)}
+                          className="h-7 w-7 p-0 text-red-500 hover:text-red-700 hover:bg-red-50"
+                        >
+                          <Trash2 className="h-4 w-4" />
+                        </Button>
+                      )}
+                    </div>
+
+                    {/* 코멘트 내용 */}
+                    <div className="bg-white rounded-md p-3 border border-gray-200">
+                      <p className="text-sm text-gray-800 whitespace-pre-wrap leading-relaxed">
+                        {comment.comment}
+                      </p>
+                    </div>
+
+                    {/* 첨부파일 */}
+                    {(comment.attachments.length > 0 || !readOnly) && (
+                      <div className="space-y-2">
+                        <div className="flex items-center justify-between">
+                          <Label className="text-xs font-medium text-gray-600 flex items-center">
+                            <Paperclip className="h-3 w-3 mr-1" />
+                            첨부파일 {comment.attachments.length > 0 ? `(${comment.attachments.length})` : ''}
+                          </Label>
+                          {!readOnly && (
+                            <label htmlFor={`file-${comment.id}`}>
+                              <Button
+                                type="button"
+                                variant="ghost"
+                                size="sm"
+                                className="h-6 text-xs"
+                                disabled={uploadingFiles.has(comment.id)}
+                                onClick={() => document.getElementById(`file-${comment.id}`)?.click()}
+                              >
+                                {uploadingFiles.has(comment.id) ? (
+                                  <Loader2 className="h-3 w-3 mr-1 animate-spin" />
+                                ) : (
+                                  <Upload className="h-3 w-3 mr-1" />
+                                )}
+                                업로드
+                              </Button>
+                              <input
+                                id={`file-${comment.id}`}
+                                type="file"
+                                className="hidden"
+                                onChange={(e) => {
+                                  const file = e.target.files?.[0];
+                                  if (file) {
+                                    handleUploadAttachment(comment.id, file);
+                                  }
+                                  e.target.value = '';
+                                }}
+                              />
+                            </label>
+                          )}
+                        </div>
+
+                        {comment.attachments.length > 0 && (
+                          <div className="space-y-1.5">
+                            {comment.attachments.map((attachment) => (
+                              <div
+                                key={attachment.id}
+                                className="flex items-center justify-between bg-white rounded p-2 border border-gray-200 hover:border-gray-300 transition-colors"
+                              >
+                                <div className="flex items-center space-x-2 flex-1 min-w-0">
+                                  <FileText className="h-4 w-4 text-blue-500 flex-shrink-0" />
+                                  <div className="flex-1 min-w-0">
+                                    <p className="text-sm text-gray-700 truncate">
+                                      {attachment.fileName}
+                                    </p>
+                                    <p className="text-xs text-gray-500">
+                                      {formatFileSize(attachment.fileSize)}
+                                    </p>
+                                  </div>
+                                </div>
+                                <div className="flex items-center space-x-1">
+                                  <Button
+                                    variant="ghost"
+                                    size="sm"
+                                    asChild
+                                    className="h-6 w-6 p-0 text-blue-500 hover:text-blue-700 hover:bg-blue-50"
+                                  >
+                                    <a href={attachment.filePath} download target="_blank" rel="noopener noreferrer">
+                                      <Download className="h-3 w-3" />
+                                    </a>
+                                  </Button>
+                                  {!readOnly && (
+                                    <Button
+                                      variant="ghost"
+                                      size="sm"
+                                      onClick={() => handleDeleteAttachment(comment.id, attachment.id)}
+                                      className="h-6 w-6 p-0 text-red-500 hover:text-red-700 hover:bg-red-50"
+                                    >
+                                      <X className="h-3 w-3" />
+                                    </Button>
+                                  )}
+                                </div>
+                              </div>
+                            ))}
+                          </div>
+                        )}
+                      </div>
+                    )}
+
+                    {/* 푸터: 작성일시 */}
+                    <div className="flex items-center justify-between text-xs text-gray-500 pt-2 border-t border-gray-200">
+                      <span>
+                        작성일: {formatDateTime(comment.createdAt, "KR")}
+                      </span>
+                      {comment.updatedAt && comment.updatedAt.getTime() !== comment.createdAt.getTime() && (
+                        <span>
+                          수정일: {formatDateTime(comment.updatedAt, "KR")}
+                        </span>
+                      )}
+                    </div>
+                  </div>
+                </CardContent>
+              </Card>
+            ))
+          )}
+        </div>
+      </ScrollArea>
+    </div>
+  );
+}
+
+
+
-- 
cgit v1.2.3

