summaryrefslogtreecommitdiff
path: root/lib/techsales-rfq/table/detail-table/vendor-communication-drawer.tsx
diff options
context:
space:
mode:
Diffstat (limited to 'lib/techsales-rfq/table/detail-table/vendor-communication-drawer.tsx')
-rw-r--r--lib/techsales-rfq/table/detail-table/vendor-communication-drawer.tsx1238
1 files changed, 620 insertions, 618 deletions
diff --git a/lib/techsales-rfq/table/detail-table/vendor-communication-drawer.tsx b/lib/techsales-rfq/table/detail-table/vendor-communication-drawer.tsx
index 0312451d..5b60ef0f 100644
--- a/lib/techsales-rfq/table/detail-table/vendor-communication-drawer.tsx
+++ b/lib/techsales-rfq/table/detail-table/vendor-communication-drawer.tsx
@@ -1,619 +1,621 @@
-"use client"
-
-import * as React from "react"
-import { useState, useEffect, useRef } from "react"
-import { RfqDetailView } from "./rfq-detail-column"
-import { Button } from "@/components/ui/button"
-import { Textarea } from "@/components/ui/textarea"
-import { Avatar, AvatarFallback } from "@/components/ui/avatar"
-import {
- Drawer,
- DrawerClose,
- DrawerContent,
- DrawerDescription,
- DrawerFooter,
- DrawerHeader,
- DrawerTitle,
-} from "@/components/ui/drawer"
-import { Badge } from "@/components/ui/badge"
-import { toast } from "sonner"
-import {
- Send,
- Paperclip,
- DownloadCloud,
- File,
- FileText,
- Image as ImageIcon,
- AlertCircle,
- X
-} from "lucide-react"
-import {
- Dialog,
- DialogContent,
- DialogDescription,
- DialogHeader,
- DialogTitle,
-} from "@/components/ui/dialog"
-import { formatDateTime } from "@/lib/utils"
-import { formatFileSize } from "@/lib/utils" // formatFileSize 유틸리티 임포트
-import { fetchTechSalesVendorComments, markTechSalesMessagesAsRead } from "@/lib/techsales-rfq/service"
-
-// 타입 정의
-interface Comment {
- id: number;
- rfqId: number;
- vendorId: number | null // null 허용으로 변경
- userId?: number | null // null 허용으로 변경
- content: string;
- isVendorComment: boolean | null; // null 허용으로 변경
- createdAt: Date;
- updatedAt: Date;
- userName?: string | null // null 허용으로 변경
- vendorName?: string | null // null 허용으로 변경
- attachments: Attachment[];
- isRead: boolean | null // null 허용으로 변경
-}
-
-interface Attachment {
- id: number;
- fileName: string;
- fileSize: number;
- fileType: string | null;
- filePath: string;
- uploadedAt: Date;
-}
-
-// 프롭스 정의
-interface VendorCommunicationDrawerProps {
- open: boolean;
- onOpenChange: (open: boolean) => void;
- selectedRfq: {
- id: number;
- rfqCode: string | null;
- status: string;
- [key: string]: any; // eslint-disable-line @typescript-eslint/no-explicit-any
- } | null;
- selectedVendor: RfqDetailView | null;
- onSuccess?: () => void;
-}
-
-async function sendComment(params: {
- rfqId: number;
- vendorId: number;
- content: string;
- attachments?: File[];
-}): Promise<Comment> {
- try {
- // 폼 데이터 생성 (파일 첨부를 위해)
- const formData = new FormData();
- formData.append('rfqId', params.rfqId.toString());
- formData.append('vendorId', params.vendorId.toString());
- formData.append('content', params.content);
- formData.append('isVendorComment', 'false');
-
- // 첨부파일 추가
- if (params.attachments && params.attachments.length > 0) {
- params.attachments.forEach((file) => {
- formData.append(`attachments`, file);
- });
- }
-
- // API 엔드포인트 구성 - techSales용으로 변경
- const url = `/api/tech-sales-rfqs/${params.rfqId}/vendors/${params.vendorId}/comments`;
-
- // API 호출
- const response = await fetch(url, {
- method: 'POST',
- body: formData, // multipart/form-data 형식 사용
- });
-
- if (!response.ok) {
- const errorText = await response.text();
- throw new Error(`API 요청 실패: ${response.status} ${errorText}`);
- }
-
- // 응답 데이터 파싱
- const result = await response.json();
-
- if (!result.success || !result.data) {
- throw new Error(result.message || '코멘트 전송 중 오류가 발생했습니다');
- }
-
- return result.data.comment;
- } catch (error) {
- console.error('코멘트 전송 오류:', error);
- throw error;
- }
-}
-
-export function VendorCommunicationDrawer({
- open,
- onOpenChange,
- selectedRfq,
- selectedVendor,
- onSuccess
-}: VendorCommunicationDrawerProps) {
- // 상태 관리
- const [comments, setComments] = useState<Comment[]>([]);
- const [newComment, setNewComment] = useState("");
- const [attachments, setAttachments] = useState<File[]>([]);
- const [isLoading, setIsLoading] = useState(false);
- const [isSubmitting, setIsSubmitting] = useState(false);
- const fileInputRef = useRef<HTMLInputElement>(null);
- const messagesEndRef = useRef<HTMLDivElement>(null);
-
- // 자동 새로고침 관련 상태
- const [autoRefresh, setAutoRefresh] = useState(true);
- const [lastMessageCount, setLastMessageCount] = useState(0);
- const intervalRef = useRef<NodeJS.Timeout | null>(null);
-
- // 첨부파일 관련 상태
- const [previewDialogOpen, setPreviewDialogOpen] = useState(false);
- const [selectedAttachment, setSelectedAttachment] = useState<Attachment | null>(null);
-
- // 드로어가 열릴 때 데이터 로드
- useEffect(() => {
- if (open && selectedRfq && selectedVendor) {
- loadComments();
- // 자동 새로고침 시작
- if (autoRefresh) {
- startAutoRefresh();
- }
- } else {
- // 드로어가 닫히면 자동 새로고침 중지
- stopAutoRefresh();
- }
-
- // 컴포넌트 언마운트 시 정리
- return () => {
- stopAutoRefresh();
- };
- }, [open, selectedRfq, selectedVendor, autoRefresh]);
-
- // 스크롤 최하단으로 이동
- useEffect(() => {
- if (messagesEndRef.current) {
- messagesEndRef.current.scrollIntoView({ behavior: "smooth" });
- }
- }, [comments]);
-
- // 자동 새로고침 시작
- const startAutoRefresh = () => {
- stopAutoRefresh(); // 기존 interval 정리
- intervalRef.current = setInterval(() => {
- if (open && selectedRfq && selectedVendor && !isSubmitting) {
- loadComments(true); // 자동 새로고침임을 표시
- }
- }, 60000); // 60초마다 새로고침
- };
-
- // 자동 새로고침 중지
- const stopAutoRefresh = () => {
- if (intervalRef.current) {
- clearInterval(intervalRef.current);
- intervalRef.current = null;
- }
- };
-
- // 자동 새로고침 토글
- const toggleAutoRefresh = () => {
- setAutoRefresh(prev => {
- const newValue = !prev;
- if (newValue && open) {
- startAutoRefresh();
- } else {
- stopAutoRefresh();
- }
- return newValue;
- });
- };
-
- // 코멘트 로드 함수 (자동 새로고침 여부 파라미터 추가)
- const loadComments = async (isAutoRefresh = false) => {
- if (!selectedRfq || !selectedVendor) return;
-
- try {
- // 자동 새로고침일 때는 로딩 표시하지 않음
- if (!isAutoRefresh) {
- setIsLoading(true);
- }
-
- // Server Action을 사용하여 코멘트 데이터 가져오기
- const commentsData = await fetchTechSalesVendorComments(selectedRfq.id, selectedVendor.vendorId || 0);
-
- // 새 메시지가 있는지 확인 (자동 새로고침일 때만)
- if (isAutoRefresh) {
- const newMessageCount = commentsData.length;
- if (newMessageCount > lastMessageCount && lastMessageCount > 0) {
- // 새 메시지 알림 (선택사항)
- toast.success(`새 메시지 ${newMessageCount - lastMessageCount}개가 도착했습니다`);
- }
- setLastMessageCount(newMessageCount);
- } else {
- setLastMessageCount(commentsData.length);
- }
-
- setComments(commentsData as Comment[]); // 구체적인 타입으로 캐스팅
-
- // Server Action을 사용하여 읽지 않은 메시지를 읽음 상태로 변경
- await markTechSalesMessagesAsRead(selectedRfq.id, selectedVendor.vendorId || 0);
- } catch (error) {
- console.error("코멘트 로드 오류:", error);
- if (!isAutoRefresh) { // 자동 새로고침일 때는 에러 토스트 표시하지 않음
- toast.error("메시지를 불러오는 중 오류가 발생했습니다");
- }
- } finally {
- // 항상 로딩 상태를 해제하되, 최소 200ms는 유지하여 깜빡거림 방지
- if (!isAutoRefresh) {
- setTimeout(() => {
- setIsLoading(false);
- }, 200);
- }
- }
- };
-
- // 파일 선택 핸들러
- const handleFileSelect = () => {
- fileInputRef.current?.click();
- };
-
- // 파일 변경 핸들러
- const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
- if (e.target.files && e.target.files.length > 0) {
- const newFiles = Array.from(e.target.files);
- setAttachments(prev => [...prev, ...newFiles]);
- }
- };
-
- // 파일 제거 핸들러
- const handleRemoveFile = (index: number) => {
- setAttachments(prev => prev.filter((_, i) => i !== index));
- };
-
- console.log(newComment)
-
- // 코멘트 전송 핸들러
- const handleSubmitComment = async () => {
- console.log("버튼 클릭1", selectedRfq,selectedVendor, selectedVendor?.vendorId )
- console.log(!newComment.trim() && attachments.length === 0)
-
- if (!newComment.trim() && attachments.length === 0) return;
- if (!selectedRfq || !selectedVendor || !selectedVendor.vendorId) return;
-
- console.log("버튼 클릭")
-
- try {
- setIsSubmitting(true);
-
- // API를 사용하여 새 코멘트 전송 (파일 업로드 때문에 FormData 사용)
- const newCommentObj = await sendComment({
- rfqId: selectedRfq.id,
- vendorId: selectedVendor.vendorId,
- content: newComment,
- attachments: attachments
- });
-
- // 상태 업데이트
- setComments(prev => [...prev, newCommentObj]);
- setNewComment("");
- setAttachments([]);
-
- toast.success("메시지가 전송되었습니다");
-
- // 데이터 새로고침
- if (onSuccess) {
- onSuccess();
- }
- } catch (error) {
- console.error("코멘트 전송 오류:", error);
- toast.error("메시지 전송 중 오류가 발생했습니다");
- } finally {
- setIsSubmitting(false);
- }
- };
-
- // 첨부파일 미리보기
- const handleAttachmentPreview = (attachment: Attachment) => {
- setSelectedAttachment(attachment);
- setPreviewDialogOpen(true);
- };
-
- // 첨부파일 다운로드
- const handleAttachmentDownload = (attachment: Attachment) => {
- // TODO: 실제 다운로드 구현
- window.open(attachment.filePath, '_blank');
- };
-
- // 파일 아이콘 선택
- const getFileIcon = (fileType: string) => {
- if (fileType.startsWith("image/")) return <ImageIcon className="h-5 w-5 text-blue-500" />;
- if (fileType.includes("pdf")) return <FileText className="h-5 w-5 text-red-500" />;
- if (fileType.includes("spreadsheet") || fileType.includes("excel"))
- return <FileText className="h-5 w-5 text-green-500" />;
- if (fileType.includes("document") || fileType.includes("word"))
- return <FileText className="h-5 w-5 text-blue-500" />;
- return <File className="h-5 w-5 text-gray-500" />;
- };
-
- // 첨부파일 미리보기 다이얼로그
- const renderAttachmentPreviewDialog = () => {
- if (!selectedAttachment) return null;
-
- const isImage = selectedAttachment.fileType?.startsWith("image/");
- const isPdf = selectedAttachment.fileType?.includes("pdf");
-
- return (
- <Dialog open={previewDialogOpen} onOpenChange={setPreviewDialogOpen}>
- <DialogContent className="max-w-3xl">
- <DialogHeader>
- <DialogTitle className="flex items-center gap-2">
- {getFileIcon(selectedAttachment.fileType || '')}
- {selectedAttachment.fileName}
- </DialogTitle>
- <DialogDescription>
- {formatFileSize(selectedAttachment.fileSize)} • {formatDateTime(selectedAttachment.uploadedAt, "KR")}
- </DialogDescription>
- </DialogHeader>
-
- <div className="min-h-[300px] flex items-center justify-center p-4">
- {isImage ? (
- <img
- src={selectedAttachment.filePath}
- alt={selectedAttachment.fileName}
- className="max-h-[500px] max-w-full object-contain"
- />
- ) : isPdf ? (
- <iframe
- src={`${selectedAttachment.filePath}#toolbar=0`}
- className="w-full h-[500px]"
- title={selectedAttachment.fileName}
- />
- ) : (
- <div className="flex flex-col items-center gap-4 p-8">
- {getFileIcon(selectedAttachment.fileType || '')}
- <p className="text-muted-foreground text-sm">미리보기를 지원하지 않는 파일 형식입니다.</p>
- <Button
- variant="outline"
- onClick={() => handleAttachmentDownload(selectedAttachment)}
- >
- <DownloadCloud className="h-4 w-4 mr-2" />
- 다운로드
- </Button>
- </div>
- )}
- </div>
- </DialogContent>
- </Dialog>
- );
- };
-
- if (!selectedRfq || !selectedVendor) {
- return null;
- }
-
- return (
- <Drawer open={open} onOpenChange={onOpenChange}>
- <DrawerContent className="max-h-[80vh] flex flex-col">
- <DrawerHeader className="border-b flex-shrink-0">
- <DrawerTitle className="flex items-center gap-2">
- <Avatar className="h-8 w-8">
- <AvatarFallback className="bg-primary/10">
- {selectedVendor.vendorName?.[0] || 'V'}
- </AvatarFallback>
- </Avatar>
- <div>
- <span>{selectedVendor.vendorName}</span>
- <Badge variant="outline" className="ml-2">{selectedVendor.vendorCode}</Badge>
- </div>
- </DrawerTitle>
- <DrawerDescription>
- RFQ: {selectedRfq.rfqCode} • 프로젝트: {selectedRfq.projectName}
- </DrawerDescription>
- </DrawerHeader>
-
- <div className="flex flex-col flex-1 min-h-0">
- {/* 메시지 목록 */}
- <div className="flex-1 p-4 overflow-y-auto min-h-[300px]">
- {isLoading && comments.length === 0 ? (
- <div className="flex h-full items-center justify-center">
- <p className="text-muted-foreground">메시지 로딩 중...</p>
- </div>
- ) : comments.length === 0 ? (
- <div className="flex h-full items-center justify-center">
- <div className="flex flex-col items-center gap-2">
- <AlertCircle className="h-6 w-6 text-muted-foreground" />
- <p className="text-muted-foreground">아직 메시지가 없습니다</p>
- </div>
- </div>
- ) : (
- <div className="space-y-4 relative">
- {isLoading && (
- <div className="absolute top-0 right-0 z-10 bg-background/80 backdrop-blur-sm rounded-md px-2 py-1">
- <div className="flex items-center gap-2">
- <div className="w-2 h-2 bg-primary rounded-full animate-pulse" />
- <span className="text-xs text-muted-foreground">새로고침 중...</span>
- </div>
- </div>
- )}
- {comments.map(comment => (
- <div
- key={comment.id}
- className={`flex gap-3 ${comment.isVendorComment ? 'justify-start' : 'justify-end'}`}
- >
- {comment.isVendorComment && (
- <Avatar className="h-8 w-8 mt-1">
- <AvatarFallback className="bg-primary/10">
- {comment.vendorName?.[0] || 'V'}
- </AvatarFallback>
- </Avatar>
- )}
-
- <div className={`rounded-lg p-3 max-w-[80%] ${
- comment.isVendorComment
- ? 'bg-muted'
- : 'bg-primary text-primary-foreground'
- }`}>
- <div className="text-sm font-medium mb-1">
- {comment.isVendorComment ? comment.vendorName : comment.userName}
- </div>
-
- {comment.content && (
- <div className="text-sm whitespace-pre-wrap break-words">
- {comment.content}
- </div>
- )}
-
- {/* 첨부파일 표시 */}
- {comment.attachments.length > 0 && (
- <div className={`mt-2 pt-2 ${
- comment.isVendorComment
- ? 'border-t border-t-border/30'
- : 'border-t border-t-primary-foreground/20'
- }`}>
- {comment.attachments.map(attachment => (
- <div
- key={attachment.id}
- className="flex items-center text-xs gap-2 mb-1 p-1 rounded hover:bg-black/5 cursor-pointer"
- onClick={() => handleAttachmentPreview(attachment)}
- >
- {getFileIcon(attachment.fileType || '')}
- <span className="flex-1 truncate">{attachment.fileName}</span>
- <span className="text-xs opacity-70">
- {formatFileSize(attachment.fileSize)}
- </span>
- <Button
- variant="ghost"
- size="icon"
- className="h-6 w-6 rounded-full"
- onClick={(e) => {
- e.stopPropagation();
- handleAttachmentDownload(attachment);
- }}
- >
- <DownloadCloud className="h-3 w-3" />
- </Button>
- </div>
- ))}
- </div>
- )}
-
- <div className="text-xs mt-1 opacity-70 flex items-center gap-1 justify-end">
- {formatDateTime(comment.createdAt)}
- </div>
- </div>
-
- {!comment.isVendorComment && (
- <Avatar className="h-8 w-8 mt-1">
- <AvatarFallback className="bg-primary/20">
- {comment.userName?.[0] || 'U'}
- </AvatarFallback>
- </Avatar>
- )}
- </div>
- ))}
- <div ref={messagesEndRef} />
- </div>
- )}
- </div>
-
- {/* 선택된 첨부파일 표시 */}
- {attachments.length > 0 && (
- <div className="p-2 bg-muted mx-4 rounded-md mb-2 flex-shrink-0">
- <div className="text-xs font-medium mb-1">첨부파일</div>
- <div className="flex flex-wrap gap-2">
- {attachments.map((file, index) => (
- <div key={index} className="flex items-center bg-background rounded-md p-1 pr-2 text-xs">
- {file.type.startsWith("image/") ? (
- <ImageIcon className="h-4 w-4 mr-1 text-blue-500" />
- ) : (
- <File className="h-4 w-4 mr-1 text-gray-500" />
- )}
- <span className="truncate max-w-[100px]">{file.name}</span>
- <Button
- variant="ghost"
- size="icon"
- className="h-4 w-4 ml-1 p-0"
- onClick={() => handleRemoveFile(index)}
- >
- <X className="h-3 w-3" />
- </Button>
- </div>
- ))}
- </div>
- </div>
- )}
-
- {/* 메시지 입력 영역 */}
- <div className="p-4 border-t flex-shrink-0">
- <div className="flex gap-2 items-end">
- <div className="flex-1">
- <Textarea
- placeholder="메시지를 입력하세요..."
- className="min-h-[80px] resize-none"
- value={newComment}
- onChange={(e) => setNewComment(e.target.value)}
- />
- </div>
- <div className="flex flex-col gap-2">
- <input
- type="file"
- ref={fileInputRef}
- className="hidden"
- multiple
- onChange={handleFileChange}
- />
- <Button
- variant="outline"
- size="icon"
- onClick={handleFileSelect}
- title="파일 첨부"
- >
- <Paperclip className="h-4 w-4" />
- </Button>
- <Button
- onClick={handleSubmitComment}
- disabled={(!newComment.trim() && attachments.length === 0) || isSubmitting}
- >
- <Send className="h-4 w-4" />
- </Button>
- </div>
- </div>
- </div>
- </div>
-
- <DrawerFooter className="border-t flex-shrink-0">
- <div className="flex justify-between items-center">
- <div className="flex items-center gap-2">
- <Button variant="outline" onClick={() => loadComments()}>
- 새로고침
- </Button>
- <Button
- variant={autoRefresh ? "default" : "outline"}
- size="sm"
- onClick={toggleAutoRefresh}
- className="gap-2"
- >
- {autoRefresh ? (
- <>
- <div className="w-2 h-2 bg-green-500 rounded-full animate-pulse" />
- 자동 새로고침 ON
- </>
- ) : (
- <>
- <div className="w-2 h-2 bg-gray-400 rounded-full" />
- 자동 새로고침 OFF
- </>
- )}
- </Button>
- </div>
- <DrawerClose asChild>
- <Button variant="outline">닫기</Button>
- </DrawerClose>
- </div>
- </DrawerFooter>
- </DrawerContent>
-
- {renderAttachmentPreviewDialog()}
- </Drawer>
- );
+"use client"
+
+import * as React from "react"
+import { useState, useEffect, useRef } from "react"
+import { RfqDetailView } from "./rfq-detail-column"
+import { Button } from "@/components/ui/button"
+import { Textarea } from "@/components/ui/textarea"
+import { Avatar, AvatarFallback } from "@/components/ui/avatar"
+import {
+ Drawer,
+ DrawerClose,
+ DrawerContent,
+ DrawerDescription,
+ DrawerFooter,
+ DrawerHeader,
+ DrawerTitle,
+} from "@/components/ui/drawer"
+import { Badge } from "@/components/ui/badge"
+import { toast } from "sonner"
+import {
+ Send,
+ Paperclip,
+ DownloadCloud,
+ File,
+ FileText,
+ Image as ImageIcon,
+ AlertCircle,
+ X
+} from "lucide-react"
+import {
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogHeader,
+ DialogTitle,
+} from "@/components/ui/dialog"
+import { formatDateTime } from "@/lib/utils"
+import { formatFileSize } from "@/lib/utils" // formatFileSize 유틸리티 임포트
+import { fetchTechSalesVendorComments, markTechSalesMessagesAsRead } from "@/lib/techsales-rfq/service"
+
+// 타입 정의
+interface Comment {
+ id: number;
+ rfqId: number;
+ vendorId: number | null // null 허용으로 변경
+ userId?: number | null // null 허용으로 변경
+ content: string;
+ isVendorComment: boolean | null; // null 허용으로 변경
+ createdAt: Date;
+ updatedAt: Date;
+ userName?: string | null // null 허용으로 변경
+ vendorName?: string | null // null 허용으로 변경
+ attachments: Attachment[];
+ isRead: boolean | null // null 허용으로 변경
+}
+
+interface Attachment {
+ id: number;
+ fileName: string;
+ originalFileName: string;
+ fileSize: number;
+ fileType: string | null;
+ filePath: string;
+ uploadedAt: Date;
+}
+
+// 프롭스 정의
+interface VendorCommunicationDrawerProps {
+ open: boolean;
+ onOpenChange: (open: boolean) => void;
+ selectedRfq: {
+ id: number;
+ rfqCode: string | null;
+ status: string;
+ [key: string]: any; // eslint-disable-line @typescript-eslint/no-explicit-any
+ } | null;
+ selectedVendor: RfqDetailView | null;
+ onSuccess?: () => void;
+}
+
+async function sendComment(params: {
+ rfqId: number;
+ vendorId: number;
+ content: string;
+ attachments?: File[];
+}): Promise<Comment> {
+ try {
+ // 폼 데이터 생성 (파일 첨부를 위해)
+ const formData = new FormData();
+ formData.append('rfqId', params.rfqId.toString());
+ formData.append('vendorId', params.vendorId.toString());
+ formData.append('content', params.content);
+ formData.append('isVendorComment', 'false');
+
+ // 첨부파일 추가
+ if (params.attachments && params.attachments.length > 0) {
+ params.attachments.forEach((file) => {
+ formData.append(`attachments`, file);
+ });
+ }
+
+ // API 엔드포인트 구성 - techSales용으로 변경
+ const url = `/api/tech-sales-rfqs/${params.rfqId}/vendors/${params.vendorId}/comments`;
+
+ // API 호출
+ const response = await fetch(url, {
+ method: 'POST',
+ body: formData, // multipart/form-data 형식 사용
+ });
+
+ if (!response.ok) {
+ const errorText = await response.text();
+ throw new Error(`API 요청 실패: ${response.status} ${errorText}`);
+ }
+
+ // 응답 데이터 파싱
+ const result = await response.json();
+
+ if (!result.success || !result.data) {
+ throw new Error(result.message || '코멘트 전송 중 오류가 발생했습니다');
+ }
+
+ return result.data.comment;
+ } catch (error) {
+ console.error('코멘트 전송 오류:', error);
+ throw error;
+ }
+}
+
+export function VendorCommunicationDrawer({
+ open,
+ onOpenChange,
+ selectedRfq,
+ selectedVendor,
+ onSuccess
+}: VendorCommunicationDrawerProps) {
+ // 상태 관리
+ const [comments, setComments] = useState<Comment[]>([]);
+ const [newComment, setNewComment] = useState("");
+ const [attachments, setAttachments] = useState<File[]>([]);
+ const [isLoading, setIsLoading] = useState(false);
+ const [isSubmitting, setIsSubmitting] = useState(false);
+ const fileInputRef = useRef<HTMLInputElement>(null);
+ const messagesEndRef = useRef<HTMLDivElement>(null);
+
+ // 자동 새로고침 관련 상태
+ const [autoRefresh, setAutoRefresh] = useState(true);
+ const [lastMessageCount, setLastMessageCount] = useState(0);
+ const intervalRef = useRef<NodeJS.Timeout | null>(null);
+
+ // 첨부파일 관련 상태
+ const [previewDialogOpen, setPreviewDialogOpen] = useState(false);
+ const [selectedAttachment, setSelectedAttachment] = useState<Attachment | null>(null);
+
+ // 드로어가 열릴 때 데이터 로드
+ useEffect(() => {
+ if (open && selectedRfq && selectedVendor) {
+ loadComments();
+ // 자동 새로고침 시작
+ if (autoRefresh) {
+ startAutoRefresh();
+ }
+ } else {
+ // 드로어가 닫히면 자동 새로고침 중지
+ stopAutoRefresh();
+ }
+
+ // 컴포넌트 언마운트 시 정리
+ return () => {
+ stopAutoRefresh();
+ };
+ }, [open, selectedRfq, selectedVendor, autoRefresh]);
+
+ // 스크롤 최하단으로 이동
+ useEffect(() => {
+ if (messagesEndRef.current) {
+ messagesEndRef.current.scrollIntoView({ behavior: "smooth" });
+ }
+ }, [comments]);
+
+ // 자동 새로고침 시작
+ const startAutoRefresh = () => {
+ stopAutoRefresh(); // 기존 interval 정리
+ intervalRef.current = setInterval(() => {
+ if (open && selectedRfq && selectedVendor && !isSubmitting) {
+ loadComments(true); // 자동 새로고침임을 표시
+ }
+ }, 60000); // 60초마다 새로고침
+ };
+
+ // 자동 새로고침 중지
+ const stopAutoRefresh = () => {
+ if (intervalRef.current) {
+ clearInterval(intervalRef.current);
+ intervalRef.current = null;
+ }
+ };
+
+ // 자동 새로고침 토글
+ const toggleAutoRefresh = () => {
+ setAutoRefresh(prev => {
+ const newValue = !prev;
+ if (newValue && open) {
+ startAutoRefresh();
+ } else {
+ stopAutoRefresh();
+ }
+ return newValue;
+ });
+ };
+
+ // 코멘트 로드 함수 (자동 새로고침 여부 파라미터 추가)
+ const loadComments = async (isAutoRefresh = false) => {
+ if (!selectedRfq || !selectedVendor) return;
+
+ try {
+ // 자동 새로고침일 때는 로딩 표시하지 않음
+ if (!isAutoRefresh) {
+ setIsLoading(true);
+ }
+
+ // Server Action을 사용하여 코멘트 데이터 가져오기
+ const commentsData = await fetchTechSalesVendorComments(selectedRfq.id, selectedVendor.vendorId || 0);
+
+ // 새 메시지가 있는지 확인 (자동 새로고침일 때만)
+ if (isAutoRefresh) {
+ const newMessageCount = commentsData.length;
+ if (newMessageCount > lastMessageCount && lastMessageCount > 0) {
+ // 새 메시지 알림 (선택사항)
+ toast.success(`새 메시지 ${newMessageCount - lastMessageCount}개가 도착했습니다`);
+ }
+ setLastMessageCount(newMessageCount);
+ } else {
+ setLastMessageCount(commentsData.length);
+ }
+
+ setComments(commentsData as Comment[]); // 구체적인 타입으로 캐스팅
+
+ // Server Action을 사용하여 읽지 않은 메시지를 읽음 상태로 변경
+ await markTechSalesMessagesAsRead(selectedRfq.id, selectedVendor.vendorId || 0);
+ } catch (error) {
+ console.error("코멘트 로드 오류:", error);
+ if (!isAutoRefresh) { // 자동 새로고침일 때는 에러 토스트 표시하지 않음
+ toast.error("메시지를 불러오는 중 오류가 발생했습니다");
+ }
+ } finally {
+ // 항상 로딩 상태를 해제하되, 최소 200ms는 유지하여 깜빡거림 방지
+ if (!isAutoRefresh) {
+ setTimeout(() => {
+ setIsLoading(false);
+ }, 200);
+ }
+ }
+ };
+
+ // 파일 선택 핸들러
+ const handleFileSelect = () => {
+ fileInputRef.current?.click();
+ };
+
+ // 파일 변경 핸들러
+ const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
+ if (e.target.files && e.target.files.length > 0) {
+ const newFiles = Array.from(e.target.files);
+ setAttachments(prev => [...prev, ...newFiles]);
+ }
+ };
+
+ // 파일 제거 핸들러
+ const handleRemoveFile = (index: number) => {
+ setAttachments(prev => prev.filter((_, i) => i !== index));
+ };
+
+ console.log(newComment)
+
+ // 코멘트 전송 핸들러
+ const handleSubmitComment = async () => {
+ console.log("버튼 클릭1", selectedRfq,selectedVendor, selectedVendor?.vendorId )
+ console.log(!newComment.trim() && attachments.length === 0)
+
+ if (!newComment.trim() && attachments.length === 0) return;
+ if (!selectedRfq || !selectedVendor || !selectedVendor.vendorId) return;
+
+ console.log("버튼 클릭")
+
+ try {
+ setIsSubmitting(true);
+
+ // API를 사용하여 새 코멘트 전송 (파일 업로드 때문에 FormData 사용)
+ const newCommentObj = await sendComment({
+ rfqId: selectedRfq.id,
+ vendorId: selectedVendor.vendorId,
+ content: newComment,
+ attachments: attachments
+ });
+
+ // 상태 업데이트
+ setComments(prev => [...prev, newCommentObj]);
+ setNewComment("");
+ setAttachments([]);
+
+ toast.success("메시지가 전송되었습니다");
+
+ // 데이터 새로고침
+ if (onSuccess) {
+ onSuccess();
+ }
+ } catch (error) {
+ console.error("코멘트 전송 오류:", error);
+ toast.error("메시지 전송 중 오류가 발생했습니다");
+ } finally {
+ setIsSubmitting(false);
+ }
+ };
+
+ // 첨부파일 미리보기
+ const handleAttachmentPreview = (attachment: Attachment) => {
+ setSelectedAttachment(attachment);
+ setPreviewDialogOpen(true);
+ };
+
+ // 첨부파일 다운로드
+ const handleAttachmentDownload = (attachment: Attachment) => {
+ // TODO: 실제 다운로드 구현
+ window.open(attachment.filePath, '_blank');
+ };
+
+ // 파일 아이콘 선택
+ const getFileIcon = (fileType: string) => {
+ if (fileType.startsWith("image/")) return <ImageIcon className="h-5 w-5 text-blue-500" />;
+ if (fileType.includes("pdf")) return <FileText className="h-5 w-5 text-red-500" />;
+ if (fileType.includes("spreadsheet") || fileType.includes("excel"))
+ return <FileText className="h-5 w-5 text-green-500" />;
+ if (fileType.includes("document") || fileType.includes("word"))
+ return <FileText className="h-5 w-5 text-blue-500" />;
+ return <File className="h-5 w-5 text-gray-500" />;
+ };
+
+ // 첨부파일 미리보기 다이얼로그
+ const renderAttachmentPreviewDialog = () => {
+ if (!selectedAttachment) return null;
+
+ const isImage = selectedAttachment.fileType?.startsWith("image/");
+ const isPdf = selectedAttachment.fileType?.includes("pdf");
+
+ return (
+ <Dialog open={previewDialogOpen} onOpenChange={setPreviewDialogOpen}>
+ <DialogContent className="max-w-3xl">
+ <DialogHeader>
+ <DialogTitle className="flex items-center gap-2">
+ {getFileIcon(selectedAttachment.fileType || '')}
+ {selectedAttachment.originalFileName || selectedAttachment.fileName}
+ </DialogTitle>
+ <DialogDescription>
+ {formatFileSize(selectedAttachment.fileSize)} • {formatDateTime(selectedAttachment.uploadedAt, "KR")}
+ </DialogDescription>
+ </DialogHeader>
+
+ <div className="min-h-[300px] flex items-center justify-center p-4">
+ {isImage ? (
+ // eslint-disable-next-line @next/next/no-img-element
+ <img
+ src={selectedAttachment.filePath}
+ alt={selectedAttachment.originalFileName || selectedAttachment.fileName}
+ className="max-h-[500px] max-w-full object-contain"
+ />
+ ) : isPdf ? (
+ <iframe
+ src={`${selectedAttachment.filePath}#toolbar=0`}
+ className="w-full h-[500px]"
+ title={selectedAttachment.originalFileName || selectedAttachment.fileName}
+ />
+ ) : (
+ <div className="flex flex-col items-center gap-4 p-8">
+ {getFileIcon(selectedAttachment.fileType || '')}
+ <p className="text-muted-foreground text-sm">미리보기를 지원하지 않는 파일 형식입니다.</p>
+ <Button
+ variant="outline"
+ onClick={() => handleAttachmentDownload(selectedAttachment)}
+ >
+ <DownloadCloud className="h-4 w-4 mr-2" />
+ 다운로드
+ </Button>
+ </div>
+ )}
+ </div>
+ </DialogContent>
+ </Dialog>
+ );
+ };
+
+ if (!selectedRfq || !selectedVendor) {
+ return null;
+ }
+
+ return (
+ <Drawer open={open} onOpenChange={onOpenChange}>
+ <DrawerContent className="max-h-[80vh] flex flex-col">
+ <DrawerHeader className="border-b flex-shrink-0">
+ <DrawerTitle className="flex items-center gap-2">
+ <Avatar className="h-8 w-8">
+ <AvatarFallback className="bg-primary/10">
+ {selectedVendor.vendorName?.[0] || 'V'}
+ </AvatarFallback>
+ </Avatar>
+ <div>
+ <span>{selectedVendor.vendorName}</span>
+ <Badge variant="outline" className="ml-2">{selectedVendor.vendorCode}</Badge>
+ </div>
+ </DrawerTitle>
+ <DrawerDescription>
+ RFQ: {selectedRfq.rfqCode} • 프로젝트: {selectedRfq.projectName}
+ </DrawerDescription>
+ </DrawerHeader>
+
+ <div className="flex flex-col flex-1 min-h-0">
+ {/* 메시지 목록 */}
+ <div className="flex-1 p-4 overflow-y-auto min-h-[300px]">
+ {isLoading && comments.length === 0 ? (
+ <div className="flex h-full items-center justify-center">
+ <p className="text-muted-foreground">메시지 로딩 중...</p>
+ </div>
+ ) : comments.length === 0 ? (
+ <div className="flex h-full items-center justify-center">
+ <div className="flex flex-col items-center gap-2">
+ <AlertCircle className="h-6 w-6 text-muted-foreground" />
+ <p className="text-muted-foreground">아직 메시지가 없습니다</p>
+ </div>
+ </div>
+ ) : (
+ <div className="space-y-4 relative">
+ {isLoading && (
+ <div className="absolute top-0 right-0 z-10 bg-background/80 backdrop-blur-sm rounded-md px-2 py-1">
+ <div className="flex items-center gap-2">
+ <div className="w-2 h-2 bg-primary rounded-full animate-pulse" />
+ <span className="text-xs text-muted-foreground">새로고침 중...</span>
+ </div>
+ </div>
+ )}
+ {comments.map(comment => (
+ <div
+ key={comment.id}
+ className={`flex gap-3 ${comment.isVendorComment ? 'justify-start' : 'justify-end'}`}
+ >
+ {comment.isVendorComment && (
+ <Avatar className="h-8 w-8 mt-1">
+ <AvatarFallback className="bg-primary/10">
+ {comment.vendorName?.[0] || 'V'}
+ </AvatarFallback>
+ </Avatar>
+ )}
+
+ <div className={`rounded-lg p-3 max-w-[80%] ${
+ comment.isVendorComment
+ ? 'bg-muted'
+ : 'bg-primary text-primary-foreground'
+ }`}>
+ <div className="text-sm font-medium mb-1">
+ {comment.isVendorComment ? comment.vendorName : comment.userName}
+ </div>
+
+ {comment.content && (
+ <div className="text-sm whitespace-pre-wrap break-words">
+ {comment.content}
+ </div>
+ )}
+
+ {/* 첨부파일 표시 */}
+ {comment.attachments.length > 0 && (
+ <div className={`mt-2 pt-2 ${
+ comment.isVendorComment
+ ? 'border-t border-t-border/30'
+ : 'border-t border-t-primary-foreground/20'
+ }`}>
+ {comment.attachments.map(attachment => (
+ <div
+ key={attachment.id}
+ className="flex items-center text-xs gap-2 mb-1 p-1 rounded hover:bg-black/5 cursor-pointer"
+ onClick={() => handleAttachmentPreview(attachment)}
+ >
+ {getFileIcon(attachment.fileType || '')}
+ <span className="flex-1 truncate">{attachment.originalFileName || attachment.fileName}</span>
+ <span className="text-xs opacity-70">
+ {formatFileSize(attachment.fileSize)}
+ </span>
+ <Button
+ variant="ghost"
+ size="icon"
+ className="h-6 w-6 rounded-full"
+ onClick={(e) => {
+ e.stopPropagation();
+ handleAttachmentDownload(attachment);
+ }}
+ >
+ <DownloadCloud className="h-3 w-3" />
+ </Button>
+ </div>
+ ))}
+ </div>
+ )}
+
+ <div className="text-xs mt-1 opacity-70 flex items-center gap-1 justify-end">
+ {formatDateTime(comment.createdAt)}
+ </div>
+ </div>
+
+ {!comment.isVendorComment && (
+ <Avatar className="h-8 w-8 mt-1">
+ <AvatarFallback className="bg-primary/20">
+ {comment.userName?.[0] || 'U'}
+ </AvatarFallback>
+ </Avatar>
+ )}
+ </div>
+ ))}
+ <div ref={messagesEndRef} />
+ </div>
+ )}
+ </div>
+
+ {/* 선택된 첨부파일 표시 */}
+ {attachments.length > 0 && (
+ <div className="p-2 bg-muted mx-4 rounded-md mb-2 flex-shrink-0">
+ <div className="text-xs font-medium mb-1">첨부파일</div>
+ <div className="flex flex-wrap gap-2">
+ {attachments.map((file, index) => (
+ <div key={index} className="flex items-center bg-background rounded-md p-1 pr-2 text-xs">
+ {file.type.startsWith("image/") ? (
+ <ImageIcon className="h-4 w-4 mr-1 text-blue-500" />
+ ) : (
+ <File className="h-4 w-4 mr-1 text-gray-500" />
+ )}
+ <span className="truncate max-w-[100px]">{file.name}</span>
+ <Button
+ variant="ghost"
+ size="icon"
+ className="h-4 w-4 ml-1 p-0"
+ onClick={() => handleRemoveFile(index)}
+ >
+ <X className="h-3 w-3" />
+ </Button>
+ </div>
+ ))}
+ </div>
+ </div>
+ )}
+
+ {/* 메시지 입력 영역 */}
+ <div className="p-4 border-t flex-shrink-0">
+ <div className="flex gap-2 items-end">
+ <div className="flex-1">
+ <Textarea
+ placeholder="메시지를 입력하세요..."
+ className="min-h-[80px] resize-none"
+ value={newComment}
+ onChange={(e) => setNewComment(e.target.value)}
+ />
+ </div>
+ <div className="flex flex-col gap-2">
+ <input
+ type="file"
+ ref={fileInputRef}
+ className="hidden"
+ multiple
+ onChange={handleFileChange}
+ />
+ <Button
+ variant="outline"
+ size="icon"
+ onClick={handleFileSelect}
+ title="파일 첨부"
+ >
+ <Paperclip className="h-4 w-4" />
+ </Button>
+ <Button
+ onClick={handleSubmitComment}
+ disabled={(!newComment.trim() && attachments.length === 0) || isSubmitting}
+ >
+ <Send className="h-4 w-4" />
+ </Button>
+ </div>
+ </div>
+ </div>
+ </div>
+
+ <DrawerFooter className="border-t flex-shrink-0">
+ <div className="flex justify-between items-center">
+ <div className="flex items-center gap-2">
+ <Button variant="outline" onClick={() => loadComments()}>
+ 새로고침
+ </Button>
+ <Button
+ variant={autoRefresh ? "default" : "outline"}
+ size="sm"
+ onClick={toggleAutoRefresh}
+ className="gap-2"
+ >
+ {autoRefresh ? (
+ <>
+ <div className="w-2 h-2 bg-green-500 rounded-full animate-pulse" />
+ 자동 새로고침 ON
+ </>
+ ) : (
+ <>
+ <div className="w-2 h-2 bg-gray-400 rounded-full" />
+ 자동 새로고침 OFF
+ </>
+ )}
+ </Button>
+ </div>
+ <DrawerClose asChild>
+ <Button variant="outline">닫기</Button>
+ </DrawerClose>
+ </div>
+ </DrawerFooter>
+ </DrawerContent>
+
+ {renderAttachmentPreviewDialog()}
+ </Drawer>
+ );
} \ No newline at end of file