diff options
Diffstat (limited to 'lib/basic-contract/viewer')
| -rw-r--r-- | lib/basic-contract/viewer/GtcClausesComponent.tsx | 4 | ||||
| -rw-r--r-- | lib/basic-contract/viewer/clause-review-list.tsx | 475 |
2 files changed, 477 insertions, 2 deletions
diff --git a/lib/basic-contract/viewer/GtcClausesComponent.tsx b/lib/basic-contract/viewer/GtcClausesComponent.tsx index 381e69dc..e44879ab 100644 --- a/lib/basic-contract/viewer/GtcClausesComponent.tsx +++ b/lib/basic-contract/viewer/GtcClausesComponent.tsx @@ -853,8 +853,8 @@ export function GtcClausesComponent({ )} </h3> {!compactMode && ( - <p className="text-sm text-gray-500 mt-0.5"> - {gtcData.vendorDocument.description || "GTC 조항 검토 및 협의"} + <p className="text-sm text-gray-500 mt-0.5"> + {gtcData.vendorDocument.description || "GTC 협의"} </p> )} </div> 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> + ); +} + |
