diff options
Diffstat (limited to 'lib/basic-contract/agreement-comments')
| -rw-r--r-- | lib/basic-contract/agreement-comments/actions.ts | 510 | ||||
| -rw-r--r-- | lib/basic-contract/agreement-comments/agreement-comment-list.tsx | 517 |
2 files changed, 1027 insertions, 0 deletions
diff --git a/lib/basic-contract/agreement-comments/actions.ts b/lib/basic-contract/agreement-comments/actions.ts new file mode 100644 index 00000000..13db2fc6 --- /dev/null +++ b/lib/basic-contract/agreement-comments/actions.ts @@ -0,0 +1,510 @@ +"use server"; + +import { revalidateTag } from "next/cache"; +import db from "@/db/db"; +import { eq, and, desc } from "drizzle-orm"; +import { agreementComments, basicContract, vendors, users } from "@/db/schema"; +import { saveFile, deleteFile } from "@/lib/file-stroage"; +import { sendEmail } from "@/lib/mail/sendEmail"; +import { getServerSession } from "next-auth/next"; +import { authOptions } from "@/app/api/auth/[...nextauth]/route"; + +export type AgreementCommentAuthorType = 'SHI' | 'Vendor'; + +export interface AgreementCommentAttachment { + id: string; + fileName: string; + filePath: string; + fileSize: number; + uploadedAt: Date; +} + +export interface AgreementCommentData { + id: number; + basicContractId: number; + authorType: AgreementCommentAuthorType; + authorUserId: number | null; + authorVendorId: number | null; + authorName: string | null; + comment: string; + attachments: AgreementCommentAttachment[]; + createdAt: Date; + updatedAt: Date; +} + +/** + * 특정 기본계약서의 모든 코멘트 조회 + */ +export async function getAgreementComments( + basicContractId: number +): Promise<AgreementCommentData[]> { + try { + const comments = await db + .select() + .from(agreementComments) + .where( + and( + eq(agreementComments.basicContractId, basicContractId), + eq(agreementComments.isDeleted, false) + ) + ) + .orderBy(desc(agreementComments.createdAt)); + + const mappedComments: AgreementCommentData[] = comments.map((comment) => { + // attachments 안전하게 파싱 + let attachments: AgreementCommentAttachment[] = []; + + if (comment.attachments) { + try { + // 문자열인 경우 파싱 시도 + if (typeof comment.attachments === 'string') { + const trimmed = comment.attachments.trim(); + if (trimmed && trimmed !== '') { + attachments = JSON.parse(trimmed); + } + } + // 이미 배열인 경우 그대로 사용 + else if (Array.isArray(comment.attachments)) { + attachments = comment.attachments; + } + } catch (parseError) { + console.warn(`⚠️ [getAgreementComments] 코멘트 ${comment.id}의 attachments 파싱 실패:`, parseError); + console.warn(` attachments 값:`, comment.attachments); + attachments = []; // 파싱 실패 시 빈 배열로 설정 + } + } + + return { + ...comment, + authorType: comment.authorType as AgreementCommentAuthorType, + attachments, + } as AgreementCommentData; + }); + + + return mappedComments; + } catch (error) { + console.error("코멘트 조회 실패:", error); + throw new Error("코멘트 조회 중 오류가 발생했습니다."); + } +} + +/** + * 코멘트 추가 + */ +export async function addAgreementComment(data: { + basicContractId: number; + comment: string; + authorName?: string; +}): Promise<{ success: boolean; data?: AgreementCommentData; error?: string }> { + try { + const session = await getServerSession(authOptions); + if (!session?.user) { + return { success: false, error: "인증이 필요합니다." }; + } + + // 사용자 정보로부터 authorType 결정 + // companyId가 있으면 Vendor, 없으면 SHI + const user = session.user as any; + const isVendor = !!user.companyId; + const authorType: AgreementCommentAuthorType = isVendor ? 'Vendor' : 'SHI'; + + // 기본계약서 정보 조회 (이메일 발송을 위해) + const [contract] = await db + .select() + .from(basicContract) + .where(eq(basicContract.id, data.basicContractId)) + .limit(1); + + if (!contract) { + return { success: false, error: "계약서를 찾을 수 없습니다." }; + } + + // 벤더 정보 조회 (이메일 발송용) + let vendor: any = null; + if (contract.vendorId) { + [vendor] = await db + .select() + .from(vendors) + .where(eq(vendors.id, contract.vendorId)) + .limit(1); + } + + // 요청자 정보 조회 (이메일 발송용) + let requester: any = null; + if (contract.requestedBy) { + [requester] = await db + .select() + .from(users) + .where(eq(users.id, contract.requestedBy)) + .limit(1); + } + + // 템플릿 이름 조회 + const { basicContractTemplates } = await import("@/db/schema"); + let templateName: string | null = null; + if (contract.templateId) { + const [template] = await db + .select() + .from(basicContractTemplates) + .where(eq(basicContractTemplates.id, contract.templateId)) + .limit(1); + templateName = template?.templateName || null; + } + + // 코멘트 저장 + const [newComment] = await db + .insert(agreementComments) + .values({ + basicContractId: data.basicContractId, + authorType, + authorUserId: isVendor ? null : parseInt(user.id), + authorVendorId: isVendor ? user.companyId : null, + authorName: data.authorName || user.name, + comment: data.comment, + attachments: JSON.stringify([]), + }) + .returning(); + + // 이메일 알림 발송 + try { + await sendCommentNotificationEmail({ + comment: newComment, + contract, + vendor, + requester, + templateName, + authorType, + authorName: data.authorName || user.name, + }); + } catch (emailError) { + console.error("이메일 발송 실패:", emailError); + // 이메일 실패는 코멘트 저장 성공에 영향을 주지 않음 + } + + // 계약서 상태 업데이트 (협의중으로 변경) + await updateContractNegotiationStatus(data.basicContractId); + + // 캐시 무효화: 코멘트 목록 + 기본계약서 목록 + revalidateTag(`agreement-comments-${data.basicContractId}`); + revalidateTag(`basic-contracts`); // 기본계약서 목록 새로고침 + + return { + success: true, + data: { + ...newComment, + authorType: newComment.authorType as AgreementCommentAuthorType, + attachments: [], + } as AgreementCommentData, + }; + } catch (error) { + console.error("코멘트 추가 실패:", error); + return { + success: false, + error: "코멘트 추가 중 오류가 발생했습니다.", + }; + } +} + +/** + * 코멘트 삭제 + */ +export async function deleteAgreementComment( + commentId: number +): Promise<{ success: boolean; error?: string }> { + try { + const session = await getServerSession(authOptions); + if (!session?.user) { + return { success: false, error: "인증이 필요합니다." }; + } + + // 코멘트 조회 + const [comment] = await db + .select() + .from(agreementComments) + .where(eq(agreementComments.id, commentId)); + + if (!comment) { + return { success: false, error: "코멘트를 찾을 수 없습니다." }; + } + + // 권한 확인 (작성자만 삭제 가능) + const user = session.user as any; + const isVendor = !!user.companyId; + const canDelete = + (isVendor && comment.authorVendorId === user.companyId) || + (!isVendor && comment.authorUserId === parseInt(user.id)); + + if (!canDelete) { + return { success: false, error: "삭제 권한이 없습니다." }; + } + + // Soft delete + await db + .update(agreementComments) + .set({ + isDeleted: true, + deletedAt: new Date(), + updatedAt: new Date(), + }) + .where(eq(agreementComments.id, commentId)); + + // 첨부파일이 있으면 파일 시스템에서도 삭제 + if (comment.attachments) { + const attachments: AgreementCommentAttachment[] = JSON.parse( + comment.attachments + ); + for (const attachment of attachments) { + try { + await deleteFile(attachment.filePath); + } catch (fileError) { + console.error("파일 삭제 실패:", fileError); + } + } + } + + // 캐시 무효화: 코멘트 목록 + 기본계약서 목록 + revalidateTag(`agreement-comments-${comment.basicContractId}`); + revalidateTag(`basic-contracts`); // 기본계약서 목록 새로고침 + + return { success: true }; + } catch (error) { + console.error("코멘트 삭제 실패:", error); + return { + success: false, + error: "코멘트 삭제 중 오류가 발생했습니다.", + }; + } +} + +/** + * 첨부파일 업로드 + */ +export async function uploadCommentAttachment( + commentId: number, + file: File +): Promise<{ success: boolean; data?: AgreementCommentAttachment; error?: string }> { + try { + const session = await getServerSession(authOptions); + if (!session?.user) { + return { success: false, error: "인증이 필요합니다." }; + } + + // 코멘트 조회 + const [comment] = await db + .select() + .from(agreementComments) + .where(eq(agreementComments.id, commentId)); + + if (!comment) { + return { success: false, error: "코멘트를 찾을 수 없습니다." }; + } + + // 파일 저장 + const saveResult = await saveFile({ + file, + directory: "agreement-comments", + originalName: file.name, + userId: (session.user as any).id?.toString(), + }); + + if (!saveResult.success) { + return { success: false, error: saveResult.error }; + } + + // 첨부파일 정보 생성 + const newAttachment: AgreementCommentAttachment = { + id: crypto.randomUUID(), + fileName: file.name, + filePath: saveResult.publicPath!, + fileSize: file.size, + uploadedAt: new Date(), + }; + + // 기존 첨부파일에 추가 + const existingAttachments: AgreementCommentAttachment[] = comment.attachments + ? JSON.parse(comment.attachments) + : []; + existingAttachments.push(newAttachment); + + // DB 업데이트 + await db + .update(agreementComments) + .set({ + attachments: JSON.stringify(existingAttachments), + updatedAt: new Date(), + }) + .where(eq(agreementComments.id, commentId)); + + revalidateTag(`agreement-comments-${comment.basicContractId}`); + + return { success: true, data: newAttachment }; + } catch (error) { + console.error("첨부파일 업로드 실패:", error); + return { + success: false, + error: "첨부파일 업로드 중 오류가 발생했습니다.", + }; + } +} + +/** + * 첨부파일 삭제 + */ +export async function deleteCommentAttachment( + commentId: number, + attachmentId: string +): Promise<{ success: boolean; error?: string }> { + try { + const session = await getServerSession(authOptions); + if (!session?.user) { + return { success: false, error: "인증이 필요합니다." }; + } + + // 코멘트 조회 + const [comment] = await db + .select() + .from(agreementComments) + .where(eq(agreementComments.id, commentId)); + + if (!comment) { + return { success: false, error: "코멘트를 찾을 수 없습니다." }; + } + + // 첨부파일 목록에서 제거 + const attachments: AgreementCommentAttachment[] = comment.attachments + ? JSON.parse(comment.attachments) + : []; + const targetAttachment = attachments.find((a) => a.id === attachmentId); + + if (!targetAttachment) { + return { success: false, error: "첨부파일을 찾을 수 없습니다." }; + } + + const updatedAttachments = attachments.filter((a) => a.id !== attachmentId); + + // DB 업데이트 + await db + .update(agreementComments) + .set({ + attachments: JSON.stringify(updatedAttachments), + updatedAt: new Date(), + }) + .where(eq(agreementComments.id, commentId)); + + // 파일 시스템에서 삭제 + try { + await deleteFile(targetAttachment.filePath); + } catch (fileError) { + console.error("파일 삭제 실패:", fileError); + } + + revalidateTag(`agreement-comments-${comment.basicContractId}`); + + return { success: true }; + } catch (error) { + console.error("첨부파일 삭제 실패:", error); + return { + success: false, + error: "첨부파일 삭제 중 오류가 발생했습니다.", + }; + } +} + +/** + * 협의 상태 확인 (코멘트가 있는지) + */ +export async function checkNegotiationStatus( + basicContractId: number +): Promise<{ hasComments: boolean; commentCount: number }> { + try { + const comments = await db + .select() + .from(agreementComments) + .where( + and( + eq(agreementComments.basicContractId, basicContractId), + eq(agreementComments.isDeleted, false) + ) + ); + + return { + hasComments: comments.length > 0, + commentCount: comments.length, + }; + } catch (error) { + console.error("협의 상태 확인 실패:", error); + return { hasComments: false, commentCount: 0 }; + } +} + +/** + * 이메일 알림 발송 + */ +async function sendCommentNotificationEmail(params: { + comment: typeof agreementComments.$inferSelect; + contract: typeof basicContract.$inferSelect; + vendor: typeof vendors.$inferSelect | null; + requester: typeof users.$inferSelect | null; + templateName: string | null; + authorType: AgreementCommentAuthorType; + authorName: string; +}) { + const { comment, contract, vendor, requester, templateName, authorType, authorName } = params; + + // 수신자 결정 + let recipientEmail: string | undefined; + let recipientName: string | undefined; + + if (authorType === 'Vendor') { + // 벤더가 작성한 경우 -> SHI 담당자에게 발송 + if (requester) { + recipientEmail = requester.email || undefined; + recipientName = requester.name || undefined; + } + } else { + // SHI가 작성한 경우 -> 벤더에게 발송 + if (vendor) { + recipientEmail = vendor.email || undefined; + recipientName = vendor.vendorName || undefined; + } + } + + if (!recipientEmail) { + console.warn("수신자 이메일을 찾을 수 없습니다."); + return; + } + + // 이메일 발송 + await sendEmail({ + to: recipientEmail, + subject: `[eVCP] GTC 기본계약서 협의 코멘트 - ${templateName || '기본계약서'}`, + template: "agreement-comment-notification", + context: { + language: "ko", + recipientName: recipientName || "담당자", + authorName, + authorType: authorType === 'SHI' ? '삼성중공업' : '협력업체', + comment: comment.comment, + templateName: templateName || '기본계약서', + vendorName: vendor?.vendorName || '', + contractUrl: `${process.env.NEXT_PUBLIC_APP_URL}/evcp/basic-contract/${contract.id}`, + systemUrl: process.env.NEXT_PUBLIC_APP_URL || 'https://evcp.com', + currentYear: new Date().getFullYear(), + }, + }); +} + +/** + * 계약서 협의 상태 업데이트 + * 실제로 DB 상태를 변경하지 않고, gtcData 조회 시 agreementComments 존재 여부로 판단 + */ +async function updateContractNegotiationStatus(basicContractId: number) { + // agreementComments 테이블에 코멘트가 있으면 + // checkGTCCommentsForContract 함수에서 자동으로 hasComments: true 반환 + // 별도 상태 업데이트 불필요 + console.log(`✅ 계약서 ${basicContractId} 협의 코멘트 추가됨 - 목록에서 자동 반영`); +} + + + 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> + ); +} + + + |
