diff options
| author | dujinkim <dujin.kim@dtsolution.co.kr> | 2025-11-17 08:43:00 +0000 |
|---|---|---|
| committer | dujinkim <dujin.kim@dtsolution.co.kr> | 2025-11-17 08:43:00 +0000 |
| commit | 1a6774d195b5fb9e3547f3268bf3527a8718c9bf (patch) | |
| tree | 541a4367ec3ffa9abfb8a9256c24f6286628b71c /lib/basic-contract/agreement-comments/agreement-comment-list.tsx | |
| parent | d19cca70ad1689807192a8784efc3091bf677816 (diff) | |
(임수민) GTC 기본계약 코멘트 수정
Diffstat (limited to 'lib/basic-contract/agreement-comments/agreement-comment-list.tsx')
| -rw-r--r-- | lib/basic-contract/agreement-comments/agreement-comment-list.tsx | 517 |
1 files changed, 517 insertions, 0 deletions
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> + ); +} + + + |
