summaryrefslogtreecommitdiff
path: root/lib/basic-contract/viewer/clause-review-list.tsx
diff options
context:
space:
mode:
authordujinkim <dujin.kim@dtsolution.co.kr>2025-11-17 08:43:00 +0000
committerdujinkim <dujin.kim@dtsolution.co.kr>2025-11-17 08:43:00 +0000
commit1a6774d195b5fb9e3547f3268bf3527a8718c9bf (patch)
tree541a4367ec3ffa9abfb8a9256c24f6286628b71c /lib/basic-contract/viewer/clause-review-list.tsx
parentd19cca70ad1689807192a8784efc3091bf677816 (diff)
(임수민) GTC 기본계약 코멘트 수정
Diffstat (limited to 'lib/basic-contract/viewer/clause-review-list.tsx')
-rw-r--r--lib/basic-contract/viewer/clause-review-list.tsx475
1 files changed, 475 insertions, 0 deletions
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<ReviewComment, 'id' | 'createdAt'>) => Promise<void>;
+ onDeleteComment?: (commentId: string) => Promise<void>;
+ onUploadAttachment?: (commentId: string, file: File) => Promise<ReviewAttachment>;
+ onDeleteAttachment?: (commentId: string, attachmentId: string) => Promise<void>;
+ 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<Set<string>>(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 (
+ <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">
+ 총 {filteredComments.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">
+ {filterAuthorType === 'SHI'
+ ? 'SHI(삼성중공업) 코멘트만 표시됩니다.'
+ : filterAuthorType === 'Vendor'
+ ? '협력업체 코멘트만 표시됩니다.'
+ : '모든 코멘트를 표시합니다.'}
+ </p>
+ </div>
+
+ {/* 코멘트 리스트 */}
+ <ScrollArea className="flex-1">
+ <div className="p-3 space-y-3">
+ {/* 새 코멘트 입력 폼 */}
+ {isAdding && !readOnly && (
+ <Card className="border-blue-200 bg-blue-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",
+ currentUserType === 'SHI'
+ ? "bg-blue-100 text-blue-700 border-blue-300"
+ : "bg-green-100 text-green-700 border-green-300"
+ )}
+ >
+ {currentUserType === 'SHI' ? (
+ <>
+ <Building2 className="h-3 w-3 mr-1" />
+ SHI
+ </>
+ ) : (
+ <>
+ <User className="h-3 w-3 mr-1" />
+ 협력업체
+ </>
+ )}
+ </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>
+ )}
+
+ {/* 기존 코멘트 목록 */}
+ {filteredComments.length === 0 ? (
+ <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">
+ {isAdding
+ ? "첫 번째 코멘트를 작성해보세요."
+ : "아직 코멘트가 없습니다."}
+ </p>
+ </div>
+ ) : (
+ filteredComments.map((comment) => (
+ <Card
+ key={comment.id}
+ className={cn(
+ "transition-all duration-200 hover:shadow-md",
+ comment.authorType === 'SHI'
+ ? "border-blue-200 bg-blue-50/50"
+ : "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-2 py-0.5",
+ comment.authorType === 'SHI'
+ ? "bg-blue-100 text-blue-700 border-blue-300"
+ : "bg-green-100 text-green-700 border-green-300"
+ )}
+ >
+ {comment.authorType === 'SHI' ? (
+ <>
+ <Building2 className="h-3 w-3 mr-1" />
+ SHI
+ </>
+ ) : (
+ <>
+ <User className="h-3 w-3 mr-1" />
+ 협력업체
+ </>
+ )}
+ </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 && comment.attachments.length > 0) || (!readOnly && onUploadAttachment) ? (
+ <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 ? `(${comment.attachments.length})` : ''}
+ </Label>
+ {!readOnly && onUploadAttachment && (
+ <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 && 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>
+ {!readOnly && onDeleteAttachment && (
+ <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 flex-shrink-0"
+ >
+ <X className="h-3 w-3" />
+ </Button>
+ )}
+ </div>
+ ))}
+ </div>
+ )}
+ </div>
+ ) : null}
+
+ {/* 푸터: 작성일시 */}
+ <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>
+ );
+}
+