summaryrefslogtreecommitdiff
path: root/lib/techsales-rfq/vendor-response
diff options
context:
space:
mode:
authordujinkim <dujin.kim@dtsolution.co.kr>2025-07-21 07:54:26 +0000
committerdujinkim <dujin.kim@dtsolution.co.kr>2025-07-21 07:54:26 +0000
commit14f61e24947fb92dd71ec0a7196a6e815f8e66da (patch)
tree317c501d64662d05914330628f867467fba78132 /lib/techsales-rfq/vendor-response
parent194bd4bd7e6144d5c09c5e3f5476d254234dce72 (diff)
(최겸)기술영업 RFQ 담당자 초대, 요구사항 반영
Diffstat (limited to 'lib/techsales-rfq/vendor-response')
-rw-r--r--lib/techsales-rfq/vendor-response/buyer-communication-drawer.tsx1438
-rw-r--r--lib/techsales-rfq/vendor-response/detail/communication-tab.tsx416
-rw-r--r--lib/techsales-rfq/vendor-response/detail/project-info-tab.tsx296
-rw-r--r--lib/techsales-rfq/vendor-response/detail/quotation-response-tab.tsx1043
-rw-r--r--lib/techsales-rfq/vendor-response/detail/quotation-tabs.tsx166
-rw-r--r--lib/techsales-rfq/vendor-response/table/vendor-quotations-table-columns.tsx1380
-rw-r--r--lib/techsales-rfq/vendor-response/table/vendor-quotations-table.tsx1028
7 files changed, 2922 insertions, 2845 deletions
diff --git a/lib/techsales-rfq/vendor-response/buyer-communication-drawer.tsx b/lib/techsales-rfq/vendor-response/buyer-communication-drawer.tsx
index 4422a32c..c0f63ff7 100644
--- a/lib/techsales-rfq/vendor-response/buyer-communication-drawer.tsx
+++ b/lib/techsales-rfq/vendor-response/buyer-communication-drawer.tsx
@@ -1,711 +1,729 @@
-"use client"
-
-import * as React from "react"
-import { useState, useEffect, useRef } from "react"
-import { toast } from "sonner"
-import {
- Send,
- Paperclip,
- DownloadCloud,
- File,
- FileText,
- Image as ImageIcon,
- AlertCircle,
- X,
- User,
- Building
-} from "lucide-react"
-import {
- Drawer,
- DrawerClose,
- DrawerContent,
- DrawerDescription,
- DrawerFooter,
- DrawerHeader,
- DrawerTitle,
-} from "@/components/ui/drawer"
-import { Button } from "@/components/ui/button"
-import { Textarea } from "@/components/ui/textarea"
-import { Avatar, AvatarFallback } from "@/components/ui/avatar"
-import { Badge } from "@/components/ui/badge"
-import {
- Dialog,
- DialogContent,
- DialogDescription,
- DialogHeader,
- DialogTitle,
-} from "@/components/ui/dialog"
-import { formatDateTime, formatFileSize } from "@/lib/utils"
-import { useSession } from "next-auth/react"
-
-// 타입 정의
-export interface TechSalesAttachment {
- id: number
- fileName: string
- fileSize: number
- fileType: string | null
- filePath: string
- uploadedAt: Date
-}
-
-export interface TechSalesComment {
- id: number
- rfqId: number
- vendorId: number | null
- userId?: number | null
- content: string
- isVendorComment: boolean | null
- createdAt: Date
- updatedAt: Date
- userName?: string | null
- vendorName?: string | null
- attachments: TechSalesAttachment[]
- isRead: boolean | null
-}
-
-// 프롭스 정의
-interface BuyerCommunicationDrawerProps {
- open: boolean;
- onOpenChange: (open: boolean) => void;
- quotation: {
- id: number;
- rfqId: number;
- vendorId: number;
- quotationCode: string | null;
- rfq?: {
- rfqCode: string | null;
- };
- } | null;
- onSuccess?: () => void;
-}
-
-// 클라이언트에서 API를 통해 코멘트를 가져오는 함수
-export async function fetchTechSalesVendorCommentsClient(rfqId: number, vendorId: number): Promise<TechSalesComment[]> {
- const response = await fetch(`/api/tech-sales-rfqs/${rfqId}/vendors/${vendorId}/comments`);
-
- if (!response.ok) {
- throw new Error(`API 요청 실패: ${response.status}`);
- }
-
- const result = await response.json();
-
- if (!result.success) {
- throw new Error(result.message || '코멘트 조회 중 오류가 발생했습니다');
- }
-
- // API 응답 타입 정의
- interface ApiComment {
- id: number;
- rfqId: number;
- vendorId: number | null;
- userId?: number | null;
- content: string;
- isVendorComment: boolean | null;
- createdAt: string;
- updatedAt: string;
- userName?: string | null;
- vendorName?: string | null;
- isRead: boolean | null;
- attachments: Array<{
- id: number;
- fileName: string;
- fileSize: number;
- fileType: string | null;
- filePath: string;
- uploadedAt: string;
- }>;
- }
-
- return result.data.map((comment: ApiComment) => ({
- ...comment,
- createdAt: new Date(comment.createdAt),
- updatedAt: new Date(comment.updatedAt),
- attachments: comment.attachments.map((att) => ({
- ...att,
- uploadedAt: new Date(att.uploadedAt)
- }))
- }));
-}
-
-// 벤더 코멘트 전송 함수
-export function sendVendorCommentClient(params: {
- rfqId: number;
- vendorId: number;
- content: string;
- attachments?: File[];
-}): Promise<TechSalesComment> {
- // 폼 데이터 생성 (파일 첨부를 위해)
- const formData = new FormData();
- formData.append('rfqId', params.rfqId.toString());
- formData.append('vendorId', params.vendorId.toString());
- formData.append('content', params.content);
- formData.append('isVendorComment', 'true'); // 벤더가 보내는 메시지이므로 true
-
- // 첨부파일 추가
- if (params.attachments && params.attachments.length > 0) {
- params.attachments.forEach((file) => {
- formData.append(`attachments`, file);
- });
- }
-
- // API 엔드포인트 구성 (techsales API 경로)
- const url = `/api/tech-sales-rfqs/${params.rfqId}/vendors/${params.vendorId}/comments`;
-
- console.log("API 요청 시작:", { url, params });
-
- // API 호출
- return fetch(url, {
- method: 'POST',
- body: formData, // multipart/form-data 형식 사용
- })
- .then(response => {
- console.log("API 응답 상태:", response.status);
-
- if (!response.ok) {
- return response.text().then(text => {
- console.error("API 에러 응답:", text);
- throw new Error(`API 요청 실패: ${response.status} ${text}`);
- });
- }
- return response.json();
- })
- .then(result => {
- console.log("API 응답 데이터:", result);
-
- if (!result.success || !result.data) {
- throw new Error(result.message || '코멘트 전송 중 오류가 발생했습니다');
- }
-
- // API 응답 타입 정의
- interface ApiAttachment {
- id: number;
- fileName: string;
- fileSize: number;
- fileType: string | null;
- filePath: string;
- uploadedAt: string;
- }
-
- interface ApiCommentResponse {
- id: number;
- rfqId: number;
- vendorId: number | null;
- userId?: number | null;
- content: string;
- isVendorComment: boolean | null;
- createdAt: string;
- updatedAt: string;
- userName?: string | null;
- isRead: boolean | null;
- attachments: ApiAttachment[];
- }
-
- const commentData = result.data.comment as ApiCommentResponse;
-
- return {
- ...commentData,
- createdAt: new Date(commentData.createdAt),
- updatedAt: new Date(commentData.updatedAt),
- attachments: commentData.attachments.map((att) => ({
- ...att,
- uploadedAt: new Date(att.uploadedAt)
- }))
- };
- })
- .catch(error => {
- console.error("클라이언트 API 호출 에러:", error);
- throw error;
- });
-}
-
-export function BuyerCommunicationDrawer({
- open,
- onOpenChange,
- quotation,
- onSuccess
-}: BuyerCommunicationDrawerProps) {
- // 세션 정보
- const { data: session } = useSession();
-
- // 상태 관리
- const [comments, setComments] = useState<TechSalesComment[]>([]);
- 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<TechSalesAttachment | null>(null);
-
- // 드로어가 열릴 때 데이터 로드
- useEffect(() => {
- if (open && quotation) {
- loadComments();
- // 자동 새로고침 시작
- if (autoRefresh) {
- startAutoRefresh();
- }
- } else {
- // 드로어가 닫히면 자동 새로고침 중지
- stopAutoRefresh();
- }
-
- // 컴포넌트 언마운트 시 정리
- return () => {
- stopAutoRefresh();
- };
- }, [open, quotation, autoRefresh]);
-
- // 스크롤 최하단으로 이동
- useEffect(() => {
- if (messagesEndRef.current) {
- messagesEndRef.current.scrollIntoView({ behavior: "smooth" });
- }
- }, [comments]);
-
- // 자동 새로고침 시작
- const startAutoRefresh = () => {
- stopAutoRefresh(); // 기존 interval 정리
- intervalRef.current = setInterval(() => {
- if (open && quotation && !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 (!quotation) return;
-
- try {
- // 자동 새로고침일 때는 로딩 표시하지 않음
- if (!isAutoRefresh) {
- setIsLoading(true);
- }
-
- // API를 사용하여 코멘트 데이터 가져오기
- const commentsData = await fetchTechSalesVendorCommentsClient(quotation.rfqId, quotation.vendorId);
-
- // 새 메시지가 있는지 확인 (자동 새로고침일 때만)
- if (isAutoRefresh) {
- const newMessageCount = commentsData.length;
- if (newMessageCount > lastMessageCount && lastMessageCount > 0) {
- // 새 메시지 알림
- toast.success(`새 메시지 ${newMessageCount - lastMessageCount}개가 도착했습니다`);
- }
- setLastMessageCount(newMessageCount);
- } else {
- setLastMessageCount(commentsData.length);
- }
-
- setComments(commentsData);
-
- // 읽음 상태 처리는 API 측에서 처리되는 것으로 가정
- } 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));
- };
-
- // 코멘트 전송 핸들러
- const handleSubmitComment = async () => {
- if (!newComment.trim() && attachments.length === 0) return;
- if (!quotation) return;
-
- try {
- setIsSubmitting(true);
-
- // API를 사용하여 새 코멘트 전송 (파일 업로드 때문에 FormData 사용)
- const newCommentObj = await sendVendorCommentClient({
- rfqId: quotation.rfqId,
- vendorId: quotation.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: TechSalesAttachment) => {
- setSelectedAttachment(attachment);
- setPreviewDialogOpen(true);
- };
-
- // 첨부파일 다운로드
- const handleAttachmentDownload = (attachment: TechSalesAttachment) => {
- // 실제 다운로드 구현
- window.open(attachment.filePath, '_blank');
- };
-
- // 파일 아이콘 선택
- const getFileIcon = (fileType: string | null) => {
- if (!fileType) return <File className="h-5 w-5 text-gray-500" />;
- 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/") || false;
- const isPdf = selectedAttachment.fileType?.includes("pdf") || false;
-
- 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)}
- </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 (!quotation) {
- return null;
- }
-
- // 구매자 정보 (실제로는 API에서 가져와야 함)
- const buyerName = "구매 담당자";
-
- 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">
- <User className="h-4 w-4" />
- </AvatarFallback>
- </Avatar>
- <div>
- <span>{buyerName}</span>
- <Badge variant="outline" className="ml-2">구매자</Badge>
- </div>
- </DrawerTitle>
- <DrawerDescription>
- RFQ: {quotation.rfq?.rfqCode || "N/A"} • 견적서: {quotation.quotationCode}
- </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-end' : 'justify-start'}`}
- >
- {!comment.isVendorComment && (
- <Avatar className="h-8 w-8 mt-1">
- <AvatarFallback className="bg-primary/10">
- <User className="h-4 w-4" />
- </AvatarFallback>
- </Avatar>
- )}
-
- <div className={`rounded-lg p-3 max-w-[80%] ${comment.isVendorComment
- ? 'bg-primary text-primary-foreground'
- : 'bg-muted'
- }`}>
- <div className="text-sm font-medium mb-1">
- {comment.isVendorComment ? (
- session?.user?.name || "벤더"
- ) : (
- comment.userName || buyerName
- )}
- </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-primary-foreground/20'
- : 'border-t border-t-border/30'
- }`}>
- {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">
- <Building className="h-4 w-4" />
- </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 { toast } from "sonner"
+import {
+ Send,
+ Paperclip,
+ DownloadCloud,
+ File,
+ FileText,
+ Image as ImageIcon,
+ AlertCircle,
+ X,
+ User,
+ Building
+} from "lucide-react"
+import {
+ Drawer,
+ DrawerClose,
+ DrawerContent,
+ DrawerDescription,
+ DrawerFooter,
+ DrawerHeader,
+ DrawerTitle,
+} from "@/components/ui/drawer"
+import { Button } from "@/components/ui/button"
+import { Textarea } from "@/components/ui/textarea"
+import { Avatar, AvatarFallback } from "@/components/ui/avatar"
+import { Badge } from "@/components/ui/badge"
+import {
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogHeader,
+ DialogTitle,
+} from "@/components/ui/dialog"
+import { formatDateTime, formatFileSize } from "@/lib/utils"
+import { useSession } from "next-auth/react"
+
+// 타입 정의
+export interface TechSalesAttachment {
+ id: number
+ fileName: string
+ originalFileName: string
+ fileSize: number
+ fileType: string | null
+ filePath: string
+ uploadedAt: Date
+}
+
+export interface TechSalesComment {
+ id: number
+ rfqId: number
+ vendorId: number | null
+ userId?: number | null
+ content: string
+ isVendorComment: boolean | null
+ createdAt: Date
+ updatedAt: Date
+ userName?: string | null
+ vendorName?: string | null
+ attachments: TechSalesAttachment[]
+ isRead: boolean | null
+}
+
+// 프롭스 정의
+interface BuyerCommunicationDrawerProps {
+ open: boolean;
+ onOpenChange: (open: boolean) => void;
+ quotation: {
+ id: number;
+ rfqId: number;
+ vendorId: number;
+ quotationCode: string | null;
+ rfq?: {
+ rfqCode: string | null;
+ };
+ } | null;
+ onSuccess?: () => void;
+}
+
+// 클라이언트에서 API를 통해 코멘트를 가져오는 함수
+export async function fetchTechSalesVendorCommentsClient(rfqId: number, vendorId: number): Promise<TechSalesComment[]> {
+ const response = await fetch(`/api/tech-sales-rfqs/${rfqId}/vendors/${vendorId}/comments`);
+
+ if (!response.ok) {
+ throw new Error(`API 요청 실패: ${response.status}`);
+ }
+
+ const result = await response.json();
+
+ if (!result.success) {
+ throw new Error(result.message || '코멘트 조회 중 오류가 발생했습니다');
+ }
+
+ // API 응답 타입 정의
+ interface ApiComment {
+ id: number;
+ rfqId: number;
+ vendorId: number | null;
+ userId?: number | null;
+ content: string;
+ isVendorComment: boolean | null;
+ createdAt: string;
+ updatedAt: string;
+ userName?: string | null;
+ vendorName?: string | null;
+ isRead: boolean | null;
+ attachments: Array<{
+ id: number;
+ fileName: string;
+ originalFileName: string;
+ fileSize: number;
+ fileType: string | null;
+ filePath: string;
+ uploadedAt: string;
+ }>;
+ }
+
+ return result.data.map((comment: ApiComment) => ({
+ ...comment,
+ createdAt: new Date(comment.createdAt),
+ updatedAt: new Date(comment.updatedAt),
+ attachments: comment.attachments.map((att) => ({
+ ...att,
+ uploadedAt: new Date(att.uploadedAt)
+ }))
+ }));
+}
+
+// 벤더 코멘트 전송 함수
+export function sendVendorCommentClient(params: {
+ rfqId: number;
+ vendorId: number;
+ content: string;
+ attachments?: File[];
+}): Promise<TechSalesComment> {
+ // 폼 데이터 생성 (파일 첨부를 위해)
+ const formData = new FormData();
+ formData.append('rfqId', params.rfqId.toString());
+ formData.append('vendorId', params.vendorId.toString());
+ formData.append('content', params.content);
+ formData.append('isVendorComment', 'true'); // 벤더가 보내는 메시지이므로 true
+
+ // 첨부파일 추가
+ if (params.attachments && params.attachments.length > 0) {
+ params.attachments.forEach((file) => {
+ formData.append(`attachments`, file);
+ });
+ }
+
+ // API 엔드포인트 구성 (techsales API 경로)
+ const url = `/api/tech-sales-rfqs/${params.rfqId}/vendors/${params.vendorId}/comments`;
+
+ console.log("API 요청 시작:", { url, params });
+
+ // API 호출
+ return fetch(url, {
+ method: 'POST',
+ body: formData, // multipart/form-data 형식 사용
+ })
+ .then(response => {
+ console.log("API 응답 상태:", response.status);
+
+ if (!response.ok) {
+ return response.text().then(text => {
+ console.error("API 에러 응답:", text);
+ throw new Error(`API 요청 실패: ${response.status} ${text}`);
+ });
+ }
+ return response.json();
+ })
+ .then(result => {
+ console.log("API 응답 데이터:", result);
+
+ if (!result.success || !result.data) {
+ throw new Error(result.message || '코멘트 전송 중 오류가 발생했습니다');
+ }
+
+ // API 응답 타입 정의
+ interface ApiAttachment {
+ id: number;
+ fileName: string;
+ originalFileName: string;
+ fileSize: number;
+ fileType: string | null;
+ filePath: string;
+ uploadedAt: string;
+ }
+
+ interface ApiCommentResponse {
+ id: number;
+ rfqId: number;
+ vendorId: number | null;
+ userId?: number | null;
+ content: string;
+ isVendorComment: boolean | null;
+ createdAt: string;
+ updatedAt: string;
+ userName?: string | null;
+ isRead: boolean | null;
+ attachments: ApiAttachment[];
+ }
+
+ const commentData = result.data.comment as ApiCommentResponse;
+
+ return {
+ ...commentData,
+ createdAt: new Date(commentData.createdAt),
+ updatedAt: new Date(commentData.updatedAt),
+ attachments: commentData.attachments.map((att) => ({
+ ...att,
+ uploadedAt: new Date(att.uploadedAt)
+ }))
+ };
+ })
+ .catch(error => {
+ console.error("클라이언트 API 호출 에러:", error);
+ throw error;
+ });
+}
+
+export function BuyerCommunicationDrawer({
+ open,
+ onOpenChange,
+ quotation,
+ onSuccess
+}: BuyerCommunicationDrawerProps) {
+ // 세션 정보
+ const { data: session } = useSession();
+
+ // 상태 관리
+ const [comments, setComments] = useState<TechSalesComment[]>([]);
+ 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<TechSalesAttachment | null>(null);
+
+ // 드로어가 열릴 때 데이터 로드
+ useEffect(() => {
+ if (open && quotation) {
+ loadComments();
+ // 자동 새로고침 시작
+ if (autoRefresh) {
+ startAutoRefresh();
+ }
+ } else {
+ // 드로어가 닫히면 자동 새로고침 중지
+ stopAutoRefresh();
+ }
+
+ // 컴포넌트 언마운트 시 정리
+ return () => {
+ stopAutoRefresh();
+ };
+ }, [open, quotation, autoRefresh]);
+
+ // 스크롤 최하단으로 이동
+ useEffect(() => {
+ if (messagesEndRef.current) {
+ messagesEndRef.current.scrollIntoView({ behavior: "smooth" });
+ }
+ }, [comments]);
+
+ // 자동 새로고침 시작
+ const startAutoRefresh = () => {
+ stopAutoRefresh(); // 기존 interval 정리
+ intervalRef.current = setInterval(() => {
+ if (open && quotation && !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 (!quotation) return;
+
+ try {
+ // 자동 새로고침일 때는 로딩 표시하지 않음
+ if (!isAutoRefresh) {
+ setIsLoading(true);
+ }
+
+ // API를 사용하여 코멘트 데이터 가져오기
+ const commentsData = await fetchTechSalesVendorCommentsClient(quotation.rfqId, quotation.vendorId);
+
+ // 새 메시지가 있는지 확인 (자동 새로고침일 때만)
+ if (isAutoRefresh) {
+ const newMessageCount = commentsData.length;
+ if (newMessageCount > lastMessageCount && lastMessageCount > 0) {
+ // 새 메시지 알림
+ toast.success(`새 메시지 ${newMessageCount - lastMessageCount}개가 도착했습니다`);
+ }
+ setLastMessageCount(newMessageCount);
+ } else {
+ setLastMessageCount(commentsData.length);
+ }
+
+ setComments(commentsData);
+
+ // 읽음 상태 처리는 API 측에서 처리되는 것으로 가정
+ } 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));
+ };
+
+ // 코멘트 전송 핸들러
+ const handleSubmitComment = async () => {
+ if (!newComment.trim() && attachments.length === 0) return;
+ if (!quotation) return;
+
+ try {
+ setIsSubmitting(true);
+
+ // API를 사용하여 새 코멘트 전송 (파일 업로드 때문에 FormData 사용)
+ const newCommentObj = await sendVendorCommentClient({
+ rfqId: quotation.rfqId,
+ vendorId: quotation.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: TechSalesAttachment) => {
+ setSelectedAttachment(attachment);
+ setPreviewDialogOpen(true);
+ };
+
+ // 첨부파일 다운로드 핸들러
+ const handleDownloadClick = React.useCallback(async (attachment: TechSalesAttachment) => {
+ try {
+ const { downloadFile } = await import('@/lib/file-download');
+ await downloadFile(attachment.filePath, attachment.originalFileName || attachment.fileName, {
+ showToast: true,
+ onError: (error) => {
+ console.error('다운로드 오류:', error);
+ toast.error(error);
+ },
+ onSuccess: (fileName, fileSize) => {
+ console.log(`다운로드 성공: ${fileName} (${fileSize} bytes)`);
+ }
+ });
+ } catch (error) {
+ console.error('다운로드 오류:', error);
+ toast.error('파일 다운로드 중 오류가 발생했습니다.');
+ }
+ }, []);
+
+ // 파일 아이콘 선택
+ const getFileIcon = (fileType: string | null) => {
+ if (!fileType) return <File className="h-5 w-5 text-gray-500" />;
+ 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/") || false;
+ const isPdf = selectedAttachment.fileType?.includes("pdf") || false;
+
+ 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)}
+ </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={() => handleDownloadClick(selectedAttachment)}
+ >
+ <DownloadCloud className="h-4 w-4 mr-2" />
+ 다운로드
+ </Button>
+ </div>
+ )}
+ </div>
+ </DialogContent>
+ </Dialog>
+ );
+ };
+
+ if (!quotation) {
+ return null;
+ }
+
+ // 구매자 정보 (실제로는 API에서 가져와야 함)
+ const buyerName = "구매 담당자";
+
+ 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">
+ <User className="h-4 w-4" />
+ </AvatarFallback>
+ </Avatar>
+ <div>
+ <span>{buyerName}</span>
+ <Badge variant="outline" className="ml-2">구매자</Badge>
+ </div>
+ </DrawerTitle>
+ <DrawerDescription>
+ RFQ: {quotation.rfq?.rfqCode || "N/A"} • 견적서: {quotation.quotationCode}
+ </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-end' : 'justify-start'}`}
+ >
+ {!comment.isVendorComment && (
+ <Avatar className="h-8 w-8 mt-1">
+ <AvatarFallback className="bg-primary/10">
+ <User className="h-4 w-4" />
+ </AvatarFallback>
+ </Avatar>
+ )}
+
+ <div className={`rounded-lg p-3 max-w-[80%] ${comment.isVendorComment
+ ? 'bg-primary text-primary-foreground'
+ : 'bg-muted'
+ }`}>
+ <div className="text-sm font-medium mb-1">
+ {comment.isVendorComment ? (
+ session?.user?.name || "벤더"
+ ) : (
+ comment.userName || buyerName
+ )}
+ </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-primary-foreground/20'
+ : 'border-t border-t-border/30'
+ }`}>
+ {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();
+ handleDownloadClick(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">
+ <Building className="h-4 w-4" />
+ </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
diff --git a/lib/techsales-rfq/vendor-response/detail/communication-tab.tsx b/lib/techsales-rfq/vendor-response/detail/communication-tab.tsx
index 3f2a5280..5bed179e 100644
--- a/lib/techsales-rfq/vendor-response/detail/communication-tab.tsx
+++ b/lib/techsales-rfq/vendor-response/detail/communication-tab.tsx
@@ -1,209 +1,209 @@
-"use client"
-
-import * as React from "react"
-import { useState, useEffect } from "react"
-import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
-import { Button } from "@/components/ui/button"
-import { Badge } from "@/components/ui/badge"
-import { ScrollArea } from "@/components/ui/scroll-area"
-import { Skeleton } from "@/components/ui/skeleton"
-import { MessageSquare, Paperclip } from "lucide-react"
-import { fetchTechSalesVendorCommentsClient, TechSalesComment } from "../buyer-communication-drawer"
-import { BuyerCommunicationDrawer } from "../buyer-communication-drawer"
-
-interface CommunicationTabProps {
- quotation: {
- id: number
- rfqId: number
- vendorId: number
- quotationCode: string | null
- rfq: {
- id: number
- rfqCode: string | null
- createdByUser?: {
- id: number
- name: string | null
- email: string | null
- } | null
- } | null
- vendor: {
- vendorName: string
- } | null
- }
-}
-
-export function CommunicationTab({ quotation }: CommunicationTabProps) {
- const [comments, setComments] = useState<TechSalesComment[]>([]);
- const [unreadCount, setUnreadCount] = useState(0);
- const [loadingComments, setLoadingComments] = useState(false);
- const [communicationDrawerOpen, setCommunicationDrawerOpen] = useState(false);
-
- // 컴포넌트 마운트 시 메시지 미리 로드
- useEffect(() => {
- if (quotation) {
- loadCommunicationData();
- }
- }, [quotation]);
-
- // 메시지 데이터 로드 함수
- const loadCommunicationData = async () => {
- try {
- setLoadingComments(true);
- const commentsData = await fetchTechSalesVendorCommentsClient(quotation.rfqId, quotation.vendorId);
- setComments(commentsData);
-
- // 읽지 않은 메시지 수 계산 (구매자가 보낸 메시지 중 읽지 않은 것)
- const unread = commentsData.filter(
- comment => !comment.isVendorComment && !comment.isRead
- ).length;
- setUnreadCount(unread);
- } catch (error) {
- console.error("메시지 데이터 로드 오류:", error);
- } finally {
- setLoadingComments(false);
- }
- };
-
- // 커뮤니케이션 드로어가 닫힐 때 데이터 새로고침
- const handleCommunicationDrawerChange = (open: boolean) => {
- setCommunicationDrawerOpen(open);
- if (!open) {
- loadCommunicationData(); // 드로어가 닫힐 때 데이터 새로고침
- }
- };
-
- return (
- <div className="h-full flex flex-col">
- {/* 헤더 */}
- <Card className="mb-4">
- <CardHeader className="flex flex-row items-center justify-between">
- <div>
- <CardTitle className="flex items-center gap-2">
- <MessageSquare className="h-5 w-5" />
- 커뮤니케이션
- {unreadCount > 0 && (
- <Badge variant="destructive" className="ml-2">
- 새 메시지 {unreadCount}
- </Badge>
- )}
- </CardTitle>
- <CardDescription>
- RFQ {quotation.rfq?.rfqCode || "미할당"}에 대한 구매담당자와의 커뮤니케이션
- </CardDescription>
- </div>
- <Button
- onClick={() => setCommunicationDrawerOpen(true)}
- variant="outline"
- size="sm"
- >
- <MessageSquare className="h-4 w-4 mr-2" />
- {unreadCount > 0 ? "새 메시지 확인" : "메시지 보내기"}
- </Button>
- </CardHeader>
- <CardContent>
- <div className="flex items-center gap-4 text-sm text-muted-foreground">
- <span>구매담당자: {quotation.rfq?.createdByUser?.name || "N/A"}</span>
- <span>•</span>
- <span>벤더: {quotation.vendor?.vendorName}</span>
- </div>
- </CardContent>
- </Card>
-
- {/* 메시지 미리보기 */}
- <Card className="flex-1 flex flex-col min-h-0">
- <CardHeader>
- <CardTitle className="text-lg">메시지 ({comments.length})</CardTitle>
- </CardHeader>
- <CardContent>
- {loadingComments ? (
- <div className="flex items-center justify-center p-8">
- <div className="text-center">
- <Skeleton className="h-4 w-32 mx-auto mb-2" />
- <Skeleton className="h-4 w-48 mx-auto" />
- </div>
- </div>
- ) : comments.length === 0 ? (
- <div className="min-h-[200px] flex flex-col items-center justify-center text-center p-8">
- <div className="max-w-md">
- <div className="mx-auto bg-primary/10 rounded-full w-12 h-12 flex items-center justify-center mb-4">
- <MessageSquare className="h-6 w-6 text-primary" />
- </div>
- <h3 className="text-lg font-medium mb-2">아직 메시지가 없습니다</h3>
- <p className="text-muted-foreground mb-4">
- 견적서에 대한 질문이나 의견이 있으신가요? 구매자와 메시지를 주고받으세요.
- </p>
- <Button
- onClick={() => setCommunicationDrawerOpen(true)}
- className="mx-auto"
- >
- 메시지 보내기
- </Button>
- </div>
- </div>
- ) : (
- <div className="space-y-4">
- {/* 최근 메시지 3개 미리보기 */}
- <div className="space-y-2">
- <h3 className="text-sm font-medium">최근 메시지</h3>
- <ScrollArea className="h-[250px] rounded-md border p-4">
- {comments.slice(-3).map(comment => (
- <div
- key={comment.id}
- className={`p-3 mb-3 rounded-lg ${!comment.isVendorComment && !comment.isRead
- ? 'bg-primary/10 border-l-4 border-primary'
- : 'bg-muted/50'
- }`}
- >
- <div className="flex justify-between items-center mb-1">
- <span className="text-sm font-medium">
- {comment.isVendorComment
- ? '나'
- : comment.userName || '구매 담당자'}
- </span>
- <span className="text-xs text-muted-foreground">
- {new Date(comment.createdAt).toLocaleDateString()}
- </span>
- </div>
- <p className="text-sm line-clamp-2">{comment.content}</p>
- {comment.attachments.length > 0 && (
- <div className="mt-1 text-xs text-muted-foreground">
- <Paperclip className="h-3 w-3 inline mr-1" />
- 첨부파일 {comment.attachments.length}개
- </div>
- )}
- </div>
- ))}
- </ScrollArea>
- </div>
-
- <div className="flex justify-center">
- <Button
- onClick={() => setCommunicationDrawerOpen(true)}
- className="w-full"
- >
- 전체 메시지 보기 ({comments.length}개)
- </Button>
- </div>
- </div>
- )}
- </CardContent>
- </Card>
-
- {/* 커뮤니케이션 드로어 */}
- <BuyerCommunicationDrawer
- open={communicationDrawerOpen}
- onOpenChange={handleCommunicationDrawerChange}
- quotation={{
- id: quotation.id,
- rfqId: quotation.rfqId,
- vendorId: quotation.vendorId,
- quotationCode: quotation.quotationCode,
- rfq: quotation.rfq ? {
- rfqCode: quotation.rfq.rfqCode
- } : undefined
- }}
- onSuccess={loadCommunicationData}
- />
- </div>
- )
+"use client"
+
+import * as React from "react"
+import { useState, useEffect } from "react"
+import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
+import { Button } from "@/components/ui/button"
+import { Badge } from "@/components/ui/badge"
+import { ScrollArea } from "@/components/ui/scroll-area"
+import { Skeleton } from "@/components/ui/skeleton"
+import { MessageSquare, Paperclip } from "lucide-react"
+import { fetchTechSalesVendorCommentsClient, TechSalesComment } from "../buyer-communication-drawer"
+import { BuyerCommunicationDrawer } from "../buyer-communication-drawer"
+
+interface CommunicationTabProps {
+ quotation: {
+ id: number
+ rfqId: number
+ vendorId: number
+ quotationCode: string | null
+ rfq: {
+ id: number
+ rfqCode: string | null
+ createdByUser?: {
+ id: number
+ name: string | null
+ email: string | null
+ } | null
+ } | null
+ vendor: {
+ vendorName: string
+ } | null
+ }
+}
+
+export function CommunicationTab({ quotation }: CommunicationTabProps) {
+ const [comments, setComments] = useState<TechSalesComment[]>([]);
+ const [unreadCount, setUnreadCount] = useState(0);
+ const [loadingComments, setLoadingComments] = useState(false);
+ const [communicationDrawerOpen, setCommunicationDrawerOpen] = useState(false);
+
+ // 컴포넌트 마운트 시 메시지 미리 로드
+ useEffect(() => {
+ if (quotation) {
+ loadCommunicationData();
+ }
+ }, [quotation]);
+
+ // 메시지 데이터 로드 함수
+ const loadCommunicationData = async () => {
+ try {
+ setLoadingComments(true);
+ const commentsData = await fetchTechSalesVendorCommentsClient(quotation.rfqId, quotation.vendorId);
+ setComments(commentsData);
+
+ // 읽지 않은 메시지 수 계산 (구매자가 보낸 메시지 중 읽지 않은 것)
+ const unread = commentsData.filter(
+ comment => !comment.isVendorComment && !comment.isRead
+ ).length;
+ setUnreadCount(unread);
+ } catch (error) {
+ console.error("메시지 데이터 로드 오류:", error);
+ } finally {
+ setLoadingComments(false);
+ }
+ };
+
+ // 커뮤니케이션 드로어가 닫힐 때 데이터 새로고침
+ const handleCommunicationDrawerChange = (open: boolean) => {
+ setCommunicationDrawerOpen(open);
+ if (!open) {
+ loadCommunicationData(); // 드로어가 닫힐 때 데이터 새로고침
+ }
+ };
+
+ return (
+ <div className="h-full flex flex-col">
+ {/* 헤더 */}
+ <Card className="mb-4">
+ <CardHeader className="flex flex-row items-center justify-between">
+ <div>
+ <CardTitle className="flex items-center gap-2">
+ <MessageSquare className="h-5 w-5" />
+ 커뮤니케이션
+ {unreadCount > 0 && (
+ <Badge variant="destructive" className="ml-2">
+ 새 메시지 {unreadCount}
+ </Badge>
+ )}
+ </CardTitle>
+ <CardDescription>
+ RFQ {quotation.rfq?.rfqCode || "미할당"}에 대한 구매담당자와의 커뮤니케이션
+ </CardDescription>
+ </div>
+ <Button
+ onClick={() => setCommunicationDrawerOpen(true)}
+ variant="outline"
+ size="sm"
+ >
+ <MessageSquare className="h-4 w-4 mr-2" />
+ {unreadCount > 0 ? "새 메시지 확인" : "메시지 보내기"}
+ </Button>
+ </CardHeader>
+ <CardContent>
+ <div className="flex items-center gap-4 text-sm text-muted-foreground">
+ <span>구매담당자: {quotation.rfq?.createdByUser?.name || "N/A"}</span>
+ <span>•</span>
+ <span>벤더: {quotation.vendor?.vendorName}</span>
+ </div>
+ </CardContent>
+ </Card>
+
+ {/* 메시지 미리보기 */}
+ <Card className="flex-1 flex flex-col min-h-0">
+ <CardHeader>
+ <CardTitle className="text-lg">메시지 ({comments.length})</CardTitle>
+ </CardHeader>
+ <CardContent>
+ {loadingComments ? (
+ <div className="flex items-center justify-center p-8">
+ <div className="text-center">
+ <Skeleton className="h-4 w-32 mx-auto mb-2" />
+ <Skeleton className="h-4 w-48 mx-auto" />
+ </div>
+ </div>
+ ) : comments.length === 0 ? (
+ <div className="min-h-[200px] flex flex-col items-center justify-center text-center p-8">
+ <div className="max-w-md">
+ <div className="mx-auto bg-primary/10 rounded-full w-12 h-12 flex items-center justify-center mb-4">
+ <MessageSquare className="h-6 w-6 text-primary" />
+ </div>
+ <h3 className="text-lg font-medium mb-2">아직 메시지가 없습니다</h3>
+ <p className="text-muted-foreground mb-4">
+ 견적서에 대한 질문이나 의견이 있으신가요? 구매자와 메시지를 주고받으세요.
+ </p>
+ <Button
+ onClick={() => setCommunicationDrawerOpen(true)}
+ className="mx-auto"
+ >
+ 메시지 보내기
+ </Button>
+ </div>
+ </div>
+ ) : (
+ <div className="space-y-4">
+ {/* 최근 메시지 3개 미리보기 */}
+ <div className="space-y-2">
+ <h3 className="text-sm font-medium">최근 메시지</h3>
+ <ScrollArea className="h-[250px] rounded-md border p-4">
+ {comments.slice(-3).map(comment => (
+ <div
+ key={comment.id}
+ className={`p-3 mb-3 rounded-lg ${!comment.isVendorComment && !comment.isRead
+ ? 'bg-primary/10 border-l-4 border-primary'
+ : 'bg-muted/50'
+ }`}
+ >
+ <div className="flex justify-between items-center mb-1">
+ <span className="text-sm font-medium">
+ {comment.isVendorComment
+ ? '나'
+ : comment.userName || '구매 담당자'}
+ </span>
+ <span className="text-xs text-muted-foreground">
+ {new Date(comment.createdAt).toLocaleDateString()}
+ </span>
+ </div>
+ <p className="text-sm line-clamp-2">{comment.content}</p>
+ {comment.attachments.length > 0 && (
+ <div className="mt-1 text-xs text-muted-foreground">
+ <Paperclip className="h-3 w-3 inline mr-1" />
+ 첨부파일 {comment.attachments.length}개
+ </div>
+ )}
+ </div>
+ ))}
+ </ScrollArea>
+ </div>
+
+ <div className="flex justify-center">
+ <Button
+ onClick={() => setCommunicationDrawerOpen(true)}
+ className="w-full"
+ >
+ 전체 메시지 보기 ({comments.length}개)
+ </Button>
+ </div>
+ </div>
+ )}
+ </CardContent>
+ </Card>
+
+ {/* 커뮤니케이션 드로어 */}
+ <BuyerCommunicationDrawer
+ open={communicationDrawerOpen}
+ onOpenChange={handleCommunicationDrawerChange}
+ quotation={{
+ id: quotation.id,
+ rfqId: quotation.rfqId,
+ vendorId: quotation.vendorId,
+ quotationCode: quotation.quotationCode,
+ rfq: quotation.rfq ? {
+ rfqCode: quotation.rfq.rfqCode
+ } : undefined
+ }}
+ onSuccess={loadCommunicationData}
+ />
+ </div>
+ )
} \ No newline at end of file
diff --git a/lib/techsales-rfq/vendor-response/detail/project-info-tab.tsx b/lib/techsales-rfq/vendor-response/detail/project-info-tab.tsx
index a8f44474..771db896 100644
--- a/lib/techsales-rfq/vendor-response/detail/project-info-tab.tsx
+++ b/lib/techsales-rfq/vendor-response/detail/project-info-tab.tsx
@@ -1,149 +1,149 @@
-"use client"
-
-import * as React from "react"
-import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
-import { Badge } from "@/components/ui/badge"
-import { ScrollArea } from "@/components/ui/scroll-area"
-import { formatDate } from "@/lib/utils"
-
-interface ProjectInfoTabProps {
- quotation: {
- id: number
- rfq: {
- id: number
- rfqCode: string | null
- materialCode: string | null
- dueDate: Date | null
- status: string | null
- remark: string | null
- biddingProject?: {
- id: number
- pspid: string | null
- projNm: string | null
- sector: string | null
- projMsrm: string | null
- ptypeNm: string | null
- } | null
- createdByUser?: {
- id: number
- name: string | null
- email: string | null
- } | null
- } | null
- vendor: {
- id: number
- vendorName: string
- vendorCode: string | null
- } | null
- }
-}
-
-export function ProjectInfoTab({ quotation }: ProjectInfoTabProps) {
- const rfq = quotation.rfq
-
- console.log("rfq: ", rfq)
-
- if (!rfq) {
- return (
- <div className="flex items-center justify-center h-full">
- <div className="text-center">
- <h3 className="text-lg font-medium">RFQ 정보를 찾을 수 없습니다</h3>
- <p className="text-sm text-muted-foreground mt-1">
- 연결된 RFQ 정보가 없습니다.
- </p>
- </div>
- </div>
- )
- }
-
- return (
- <ScrollArea className="h-full">
- <div className="space-y-6 p-1">
- {/* RFQ 기본 정보 */}
- <Card>
- <CardHeader>
- <CardTitle className="flex items-center gap-2">
- RFQ 기본 정보
- <Badge variant="outline">{rfq.rfqCode || "미할당"}</Badge>
- </CardTitle>
- <CardDescription>
- 요청서 기본 정보 및 자재 정보
- </CardDescription>
- </CardHeader>
- <CardContent className="space-y-4">
- <div className="grid grid-cols-1 md:grid-cols-2 gap-4">
- <div className="space-y-2">
- <div className="text-sm font-medium text-muted-foreground">RFQ 번호</div>
- <div className="text-sm">{rfq.rfqCode || "미할당"}</div>
- </div>
- <div className="space-y-2">
- <div className="text-sm font-medium text-muted-foreground">자재 그룹</div>
- <div className="text-sm">{rfq.materialCode || "N/A"}</div>
- </div>
- <div className="space-y-2">
- <div className="text-sm font-medium text-muted-foreground">마감일</div>
- <div className="text-sm">
- {rfq.dueDate ? formatDate(rfq.dueDate) : "N/A"}
- </div>
- </div>
- <div className="space-y-2">
- <div className="text-sm font-medium text-muted-foreground">RFQ 상태</div>
- <div className="text-sm">{rfq.status || "N/A"}</div>
- </div>
- <div className="space-y-2">
- <div className="text-sm font-medium text-muted-foreground">담당자</div>
- <div className="text-sm">{rfq.createdByUser?.name || "N/A"}</div>
- </div>
- </div>
- {rfq.remark && (
- <div className="space-y-2">
- <div className="text-sm font-medium text-muted-foreground">비고</div>
- <div className="text-sm p-3 bg-muted rounded-md">{rfq.remark}</div>
- </div>
- )}
- </CardContent>
- </Card>
-
- {/* 프로젝트 기본 정보 */}
- {rfq.biddingProject && (
- <Card>
- <CardHeader>
- <CardTitle className="flex items-center gap-2">
- 프로젝트 기본 정보
- <Badge variant="outline">{rfq.biddingProject.pspid || "N/A"}</Badge>
- </CardTitle>
- <CardDescription>
- 연결된 프로젝트의 기본 정보
- </CardDescription>
- </CardHeader>
- <CardContent className="space-y-4">
- <div className="grid grid-cols-1 md:grid-cols-2 gap-4">
- <div className="space-y-2">
- <div className="text-sm font-medium text-muted-foreground">프로젝트 ID</div>
- <div className="text-sm">{rfq.biddingProject.pspid || "N/A"}</div>
- </div>
- <div className="space-y-2">
- <div className="text-sm font-medium text-muted-foreground">프로젝트명</div>
- <div className="text-sm">{rfq.biddingProject.projNm || "N/A"}</div>
- </div>
- <div className="space-y-2">
- <div className="text-sm font-medium text-muted-foreground">프로젝트 섹터</div>
- <div className="text-sm">{rfq.biddingProject.sector || "N/A"}</div>
- </div>
- <div className="space-y-2">
- <div className="text-sm font-medium text-muted-foreground">프로젝트 규모</div>
- <div className="text-sm">{rfq.biddingProject.projMsrm || "N/A"}</div>
- </div>
- <div className="space-y-2">
- <div className="text-sm font-medium text-muted-foreground">프로젝트 타입</div>
- <div className="text-sm">{rfq.biddingProject.ptypeNm || "N/A"}</div>
- </div>
- </div>
- </CardContent>
- </Card>
- )}
-
- </div>
- </ScrollArea>
- )
+"use client"
+
+import * as React from "react"
+import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
+import { Badge } from "@/components/ui/badge"
+import { ScrollArea } from "@/components/ui/scroll-area"
+import { formatDate } from "@/lib/utils"
+
+interface ProjectInfoTabProps {
+ quotation: {
+ id: number
+ rfq: {
+ id: number
+ rfqCode: string | null
+ materialCode: string | null
+ dueDate: Date | null
+ status: string | null
+ remark: string | null
+ biddingProject?: {
+ id: number
+ pspid: string | null
+ projNm: string | null
+ sector: string | null
+ projMsrm: string | null
+ ptypeNm: string | null
+ } | null
+ createdByUser?: {
+ id: number
+ name: string | null
+ email: string | null
+ } | null
+ } | null
+ vendor: {
+ id: number
+ vendorName: string
+ vendorCode: string | null
+ } | null
+ }
+}
+
+export function ProjectInfoTab({ quotation }: ProjectInfoTabProps) {
+ const rfq = quotation.rfq
+
+ console.log("rfq: ", rfq)
+
+ if (!rfq) {
+ return (
+ <div className="flex items-center justify-center h-full">
+ <div className="text-center">
+ <h3 className="text-lg font-medium">RFQ 정보를 찾을 수 없습니다</h3>
+ <p className="text-sm text-muted-foreground mt-1">
+ 연결된 RFQ 정보가 없습니다.
+ </p>
+ </div>
+ </div>
+ )
+ }
+
+ return (
+ <ScrollArea className="h-full">
+ <div className="space-y-6 p-1">
+ {/* RFQ 기본 정보 */}
+ <Card>
+ <CardHeader>
+ <CardTitle className="flex items-center gap-2">
+ RFQ 기본 정보
+ <Badge variant="outline">{rfq.rfqCode || "미할당"}</Badge>
+ </CardTitle>
+ <CardDescription>
+ 요청서 기본 정보 및 자재 정보
+ </CardDescription>
+ </CardHeader>
+ <CardContent className="space-y-4">
+ <div className="grid grid-cols-1 md:grid-cols-2 gap-4">
+ <div className="space-y-2">
+ <div className="text-sm font-medium text-muted-foreground">RFQ 번호</div>
+ <div className="text-sm">{rfq.rfqCode || "미할당"}</div>
+ </div>
+ <div className="space-y-2">
+ <div className="text-sm font-medium text-muted-foreground">자재 그룹</div>
+ <div className="text-sm">{rfq.materialCode || "N/A"}</div>
+ </div>
+ <div className="space-y-2">
+ <div className="text-sm font-medium text-muted-foreground">마감일</div>
+ <div className="text-sm">
+ {rfq.dueDate ? formatDate(rfq.dueDate) : "N/A"}
+ </div>
+ </div>
+ <div className="space-y-2">
+ <div className="text-sm font-medium text-muted-foreground">RFQ 상태</div>
+ <div className="text-sm">{rfq.status || "N/A"}</div>
+ </div>
+ <div className="space-y-2">
+ <div className="text-sm font-medium text-muted-foreground">담당자</div>
+ <div className="text-sm">{rfq.createdByUser?.name || "N/A"}</div>
+ </div>
+ </div>
+ {rfq.remark && (
+ <div className="space-y-2">
+ <div className="text-sm font-medium text-muted-foreground">비고</div>
+ <div className="text-sm p-3 bg-muted rounded-md">{rfq.remark}</div>
+ </div>
+ )}
+ </CardContent>
+ </Card>
+
+ {/* 프로젝트 기본 정보 */}
+ {rfq.biddingProject && (
+ <Card>
+ <CardHeader>
+ <CardTitle className="flex items-center gap-2">
+ 프로젝트 기본 정보
+ <Badge variant="outline">{rfq.biddingProject.pspid || "N/A"}</Badge>
+ </CardTitle>
+ <CardDescription>
+ 연결된 프로젝트의 기본 정보
+ </CardDescription>
+ </CardHeader>
+ <CardContent className="space-y-4">
+ <div className="grid grid-cols-1 md:grid-cols-2 gap-4">
+ <div className="space-y-2">
+ <div className="text-sm font-medium text-muted-foreground">프로젝트 ID</div>
+ <div className="text-sm">{rfq.biddingProject.pspid || "N/A"}</div>
+ </div>
+ <div className="space-y-2">
+ <div className="text-sm font-medium text-muted-foreground">프로젝트명</div>
+ <div className="text-sm">{rfq.biddingProject.projNm || "N/A"}</div>
+ </div>
+ <div className="space-y-2">
+ <div className="text-sm font-medium text-muted-foreground">프로젝트 섹터</div>
+ <div className="text-sm">{rfq.biddingProject.sector || "N/A"}</div>
+ </div>
+ <div className="space-y-2">
+ <div className="text-sm font-medium text-muted-foreground">프로젝트 규모</div>
+ <div className="text-sm">{rfq.biddingProject.projMsrm || "N/A"}</div>
+ </div>
+ <div className="space-y-2">
+ <div className="text-sm font-medium text-muted-foreground">프로젝트 타입</div>
+ <div className="text-sm">{rfq.biddingProject.ptypeNm || "N/A"}</div>
+ </div>
+ </div>
+ </CardContent>
+ </Card>
+ )}
+
+ </div>
+ </ScrollArea>
+ )
} \ No newline at end of file
diff --git a/lib/techsales-rfq/vendor-response/detail/quotation-response-tab.tsx b/lib/techsales-rfq/vendor-response/detail/quotation-response-tab.tsx
index 0425ccc9..0a56b702 100644
--- a/lib/techsales-rfq/vendor-response/detail/quotation-response-tab.tsx
+++ b/lib/techsales-rfq/vendor-response/detail/quotation-response-tab.tsx
@@ -1,522 +1,523 @@
-"use client"
-
-import * as React from "react"
-import { useState, useEffect } from "react"
-import { useRouter } from "next/navigation"
-import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
-import { Button } from "@/components/ui/button"
-import { Input } from "@/components/ui/input"
-import { Label } from "@/components/ui/label"
-import { Textarea } from "@/components/ui/textarea"
-import { Badge } from "@/components/ui/badge"
-import { ScrollArea } from "@/components/ui/scroll-area"
-import { CalendarIcon, Send, AlertCircle, Upload, X, FileText, Download } from "lucide-react"
-import { Calendar } from "@/components/ui/calendar"
-import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"
-import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
-import { Alert, AlertDescription } from "@/components/ui/alert"
-import { formatDate, cn } from "@/lib/utils"
-import { toast } from "sonner"
-import { useSession } from "next-auth/react"
-
-interface QuotationResponseTabProps {
- quotation: {
- id: number
- status: string
- totalPrice: string | null
- currency: string | null
- validUntil: Date | null
- remark: string | null
- quotationAttachments?: Array<{
- id: number
- fileName: string
- fileSize: number
- filePath: string
- description?: string | null
- }>
- rfq: {
- id: number
- rfqCode: string | null
- materialCode: string | null
- dueDate: Date | null
- status: string | null
- item?: {
- itemName: string | null
- } | null
- } | null
- vendor: {
- vendorName: string
- } | null
- }
-}
-
-const CURRENCIES = [
- { value: "KRW", label: "KRW (원)" },
- { value: "USD", label: "USD (달러)" },
- { value: "EUR", label: "EUR (유로)" },
- { value: "JPY", label: "JPY (엔)" },
- { value: "CNY", label: "CNY (위안)" },
-]
-
-export function QuotationResponseTab({ quotation }: QuotationResponseTabProps) {
- const [totalPrice, setTotalPrice] = useState(quotation.totalPrice?.toString() || "")
- const [currency, setCurrency] = useState(quotation.currency || "KRW")
- const [validUntil, setValidUntil] = useState<Date | undefined>(
- quotation.validUntil ? new Date(quotation.validUntil) : undefined
- )
- const [remark, setRemark] = useState(quotation.remark || "")
- const [isLoading, setIsLoading] = useState(false)
- const [attachments, setAttachments] = useState<Array<{
- id?: number
- fileName: string
- fileSize: number
- filePath: string
- isNew?: boolean
- file?: File
- }>>([])
- const [isUploadingFiles, setIsUploadingFiles] = useState(false)
- const router = useRouter()
- const session = useSession()
-
- // // 초기 첨부파일 데이터 로드
- // useEffect(() => {
- // if (quotation.quotationAttachments) {
- // setAttachments(quotation.quotationAttachments.map(att => ({
- // id: att.id,
- // fileName: att.fileName,
- // fileSize: att.fileSize,
- // filePath: att.filePath,
- // isNew: false
- // })))
- // }
- // }, [quotation.quotationAttachments])
-
- const rfq = quotation.rfq
- const isDueDatePassed = rfq?.dueDate ? new Date(rfq.dueDate) < new Date() : false
- const canSubmit = !["Accepted", "Rejected"].includes(quotation.status) && !isDueDatePassed
- const canEdit = !["Accepted", "Rejected"].includes(quotation.status) && !isDueDatePassed
-
- // 파일 업로드 핸들러
- const handleFileSelect = (event: React.ChangeEvent<HTMLInputElement>) => {
- const files = event.target.files
- if (!files) return
-
- Array.from(files).forEach(file => {
- setAttachments(prev => [
- ...prev,
- {
- fileName: file.name,
- fileSize: file.size,
- filePath: '',
- isNew: true,
- file
- }
- ])
- })
- }
-
- // 첨부파일 제거
- const removeAttachment = (index: number) => {
- setAttachments(prev => prev.filter((_, i) => i !== index))
- }
-
- // 파일 업로드 함수
- const uploadFiles = async () => {
- const newFiles = attachments.filter(att => att.isNew && att.file)
- if (newFiles.length === 0) return []
-
- setIsUploadingFiles(true)
- const uploadedFiles = []
-
- try {
- for (const attachment of newFiles) {
- const formData = new FormData()
- formData.append('file', attachment.file!)
-
- const response = await fetch('/api/upload', {
- method: 'POST',
- body: formData
- })
-
- if (!response.ok) throw new Error('파일 업로드 실패')
-
- const result = await response.json()
- uploadedFiles.push({
- fileName: result.fileName,
- filePath: result.url,
- fileSize: attachment.fileSize
- })
- }
- return uploadedFiles
- } catch (error) {
- console.error('파일 업로드 오류:', error)
- toast.error('파일 업로드 중 오류가 발생했습니다.')
- return []
- } finally {
- setIsUploadingFiles(false)
- }
- }
-
- const handleSubmit = async () => {
- if (!totalPrice || !currency || !validUntil) {
- toast.error("모든 필수 항목을 입력해주세요.")
- return
- }
-
- setIsLoading(true)
- try {
- // 파일 업로드 먼저 처리
- const uploadedFiles = await uploadFiles()
-
- const { submitTechSalesVendorQuotation } = await import("@/lib/techsales-rfq/service")
-
- const result = await submitTechSalesVendorQuotation({
- id: quotation.id,
- currency,
- totalPrice,
- validUntil: validUntil!,
- remark,
- attachments: uploadedFiles,
- updatedBy: parseInt(session.data?.user.id || "0")
- })
-
- if (result.error) {
- toast.error(result.error)
- } else {
- toast.success("견적서가 제출되었습니다.")
- // // 페이지 새로고침 대신 router.refresh() 사용
- // router.refresh()
- // 페이지 새로고침
- window.location.reload()
- }
- } catch {
- toast.error("제출 중 오류가 발생했습니다.")
- } finally {
- setIsLoading(false)
- }
- }
-
- const getStatusBadgeVariant = (status: string) => {
- switch (status) {
- case "Draft":
- return "secondary"
- case "Submitted":
- return "default"
- case "Revised":
- return "outline"
- case "Rejected":
- return "destructive"
- case "Accepted":
- return "success"
- default:
- return "secondary"
- }
- }
-
- const getStatusLabel = (status: string) => {
- switch (status) {
- case "Draft":
- return "초안"
- case "Submitted":
- return "제출됨"
- case "Revised":
- return "수정됨"
- case "Rejected":
- return "반려됨"
- case "Accepted":
- return "승인됨"
- default:
- return status
- }
- }
-
- return (
- <ScrollArea className="h-full">
- <div className="space-y-6 p-1">
- {/* 견적서 상태 정보 */}
- <Card>
- <CardHeader>
- <CardTitle className="flex items-center gap-2">
- 견적서 상태
- <Badge variant={getStatusBadgeVariant(quotation.status)}>
- {getStatusLabel(quotation.status)}
- </Badge>
- </CardTitle>
- <CardDescription>
- 현재 견적서 상태 및 마감일 정보
- </CardDescription>
- </CardHeader>
- <CardContent className="space-y-4">
- <div className="grid grid-cols-1 md:grid-cols-3 gap-4">
- <div className="space-y-2">
- <div className="text-sm font-medium text-muted-foreground">견적서 상태</div>
- <div className="text-sm">{getStatusLabel(quotation.status)}</div>
- </div>
- <div className="space-y-2">
- <div className="text-sm font-medium text-muted-foreground">RFQ 마감일</div>
- <div className="text-sm">
- {rfq?.dueDate ? formatDate(rfq.dueDate) : "N/A"}
- </div>
- </div>
- <div className="space-y-2">
- <div className="text-sm font-medium text-muted-foreground">남은 시간</div>
- <div className="text-sm">
- {isDueDatePassed ? (
- <span className="text-destructive">마감됨</span>
- ) : rfq?.dueDate ? (
- <span className="text-green-600">
- {Math.ceil((new Date(rfq.dueDate).getTime() - new Date().getTime()) / (1000 * 60 * 60 * 24))}일
- </span>
- ) : (
- "N/A"
- )}
- </div>
- </div>
- </div>
-
- {isDueDatePassed && (
- <Alert>
- <AlertCircle className="h-4 w-4" />
- <AlertDescription>
- RFQ 마감일이 지났습니다. 견적서를 수정하거나 제출할 수 없습니다.
- </AlertDescription>
- </Alert>
- )}
-
- {!canEdit && !isDueDatePassed && (
- <Alert>
- <AlertCircle className="h-4 w-4" />
- <AlertDescription>
- 현재 상태에서는 견적서를 수정할 수 없습니다.
- </AlertDescription>
- </Alert>
- )}
- </CardContent>
- </Card>
-
- {/* 견적 응답 폼 */}
- <Card>
- <CardHeader>
- <CardTitle>견적 응답</CardTitle>
- <CardDescription>
- 총 가격, 통화, 유효기간을 입력해주세요.
- </CardDescription>
- </CardHeader>
- <CardContent className="space-y-6">
- {/* 총 가격 */}
- <div className="space-y-2">
- <Label htmlFor="totalPrice">
- 총 가격 <span className="text-destructive">*</span>
- </Label>
- <Input
- id="totalPrice"
- type="number"
- placeholder="총 가격을 입력하세요"
- value={totalPrice}
- onChange={(e) => setTotalPrice(e.target.value)}
- disabled={!canEdit}
- className="text-right"
- />
- </div>
-
- {/* 통화 */}
- <div className="space-y-2">
- <Label htmlFor="currency">
- 통화 <span className="text-destructive">*</span>
- </Label>
- <Select value={currency} onValueChange={setCurrency} disabled={!canEdit}>
- <SelectTrigger>
- <SelectValue placeholder="통화를 선택하세요" />
- </SelectTrigger>
- <SelectContent>
- {CURRENCIES.map((curr) => (
- <SelectItem key={curr.value} value={curr.value}>
- {curr.label}
- </SelectItem>
- ))}
- </SelectContent>
- </Select>
- </div>
-
- {/* 유효기간 */}
- <div className="space-y-2">
- <Label>
- 견적 유효기간 <span className="text-destructive">*</span>
- </Label>
- <Popover>
- <PopoverTrigger asChild>
- <Button
- variant="outline"
- className={cn(
- "w-full justify-start text-left font-normal",
- !validUntil && "text-muted-foreground"
- )}
- disabled={!canEdit}
- >
- <CalendarIcon className="mr-2 h-4 w-4" />
- {validUntil ? formatDate(validUntil) : "날짜를 선택하세요"}
- </Button>
- </PopoverTrigger>
- <PopoverContent className="w-auto p-0" align="start">
- <Calendar
- mode="single"
- selected={validUntil}
- onSelect={setValidUntil}
- disabled={(date) => date < new Date()}
- initialFocus
- />
- </PopoverContent>
- </Popover>
- </div>
-
- {/* 비고 */}
- <div className="space-y-2">
- <Label htmlFor="remark">비고</Label>
- <Textarea
- id="remark"
- placeholder="추가 설명이나 조건을 입력하세요"
- value={remark}
- onChange={(e) => setRemark(e.target.value)}
- disabled={!canEdit}
- rows={4}
- />
- </div>
-
- {/* 첨부파일 */}
- <div className="space-y-4">
- <Label>첨부파일</Label>
-
- {/* 파일 업로드 버튼 */}
- {canEdit && (
- <div className="flex items-center gap-2">
- <Button
- type="button"
- variant="outline"
- size="sm"
- disabled={isUploadingFiles}
- onClick={() => document.getElementById('file-input')?.click()}
- >
- <Upload className="h-4 w-4 mr-2" />
- 파일 선택
- </Button>
- <input
- id="file-input"
- type="file"
- multiple
- onChange={handleFileSelect}
- className="hidden"
- accept=".pdf,.doc,.docx,.xls,.xlsx,.ppt,.pptx,.txt,.jpg,.jpeg,.png,.zip"
- />
- <span className="text-sm text-muted-foreground">
- PDF, 문서파일, 이미지파일, 압축파일 등
- </span>
- </div>
- )}
-
- {/* 첨부파일 목록 */}
- {attachments.length > 0 && (
- <div className="space-y-2">
- {attachments.map((attachment, index) => (
- <div
- key={index}
- className="flex items-center justify-between p-3 border rounded-lg bg-muted/50"
- >
- <div className="flex items-center gap-2">
- <FileText className="h-4 w-4 text-muted-foreground" />
- <div>
- <div className="text-sm font-medium">{attachment.fileName}</div>
- <div className="text-xs text-muted-foreground">
- {(attachment.fileSize / 1024 / 1024).toFixed(2)} MB
- {attachment.isNew && (
- <Badge variant="secondary" className="ml-2">
- 새 파일
- </Badge>
- )}
- </div>
- </div>
- </div>
- <div className="flex items-center gap-2">
- {!attachment.isNew && (
- <Button
- type="button"
- variant="ghost"
- size="sm"
- onClick={() => window.open(attachment.filePath, '_blank')}
- >
- <Download className="h-4 w-4" />
- </Button>
- )}
- {canEdit && (
- <Button
- type="button"
- variant="ghost"
- size="sm"
- onClick={() => removeAttachment(index)}
- >
- <X className="h-4 w-4" />
- </Button>
- )}
- </div>
- </div>
- ))}
- </div>
- )}
- </div>
-
- {/* 액션 버튼 */}
- {canEdit && canSubmit && (
- <div className="flex justify-center pt-4">
- <Button
- onClick={handleSubmit}
- disabled={isLoading || !totalPrice || !currency || !validUntil}
- className="w-full "
- >
- <Send className="mr-2 h-4 w-4" />
- 견적서 제출
- </Button>
- </div>
- )}
- </CardContent>
- </Card>
-
- {/* 현재 견적 정보 (읽기 전용) */}
- {quotation.totalPrice && (
- <Card>
- <CardHeader>
- <CardTitle>현재 견적 정보</CardTitle>
- <CardDescription>
- 저장된 견적 정보
- </CardDescription>
- </CardHeader>
- <CardContent className="space-y-4">
- <div className="grid grid-cols-1 md:grid-cols-3 gap-4">
- <div className="space-y-2">
- <div className="text-sm font-medium text-muted-foreground">총 가격</div>
- <div className="text-lg font-semibold">
- {parseFloat(quotation.totalPrice).toLocaleString()} {quotation.currency}
- </div>
- </div>
- <div className="space-y-2">
- <div className="text-sm font-medium text-muted-foreground">통화</div>
- <div className="text-sm">{quotation.currency}</div>
- </div>
- <div className="space-y-2">
- <div className="text-sm font-medium text-muted-foreground">유효기간</div>
- <div className="text-sm">
- {quotation.validUntil ? formatDate(quotation.validUntil) : "N/A"}
- </div>
- </div>
- </div>
- {quotation.remark && (
- <div className="space-y-2">
- <div className="text-sm font-medium text-muted-foreground">비고</div>
- <div className="text-sm p-3 bg-muted rounded-md">{quotation.remark}</div>
- </div>
- )}
- </CardContent>
- </Card>
- )}
- </div>
- </ScrollArea>
- )
+"use client"
+
+import * as React from "react"
+import { useState } from "react"
+import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
+import { Button } from "@/components/ui/button"
+import { Input } from "@/components/ui/input"
+import { Label } from "@/components/ui/label"
+import { Textarea } from "@/components/ui/textarea"
+import { Badge } from "@/components/ui/badge"
+import { ScrollArea } from "@/components/ui/scroll-area"
+import { CalendarIcon, Send, AlertCircle, X, FileText, Download, History, FileIcon } from "lucide-react"
+import { Calendar } from "@/components/ui/calendar"
+import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"
+import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
+import { Alert, AlertDescription } from "@/components/ui/alert"
+import { formatDate, cn } from "@/lib/utils"
+import { toast } from "sonner"
+import { useSession } from "next-auth/react"
+import { QuotationHistoryDialog } from "@/lib/techsales-rfq/table/detail-table/quotation-history-dialog"
+
+interface QuotationResponseTabProps {
+ quotation: {
+ id: number
+ status: string
+ totalPrice: string | null
+ currency: string | null
+ validUntil: Date | null
+ remark: string | null
+ quotationAttachments?: Array<{
+ id: number
+ fileName: string
+ fileSize: number
+ filePath: string
+ description?: string | null
+ }>
+ rfq: {
+ id: number
+ rfqCode: string | null
+ materialCode: string | null
+ dueDate: Date | null
+ status: string | null
+ item?: {
+ itemName: string | null
+ } | null
+ } | null
+ vendor: {
+ vendorName: string
+ } | null
+ }
+}
+
+const CURRENCIES = [
+ { value: "KRW", label: "KRW (원)" },
+ { value: "USD", label: "USD (달러)" },
+ { value: "EUR", label: "EUR (유로)" },
+ { value: "JPY", label: "JPY (엔)" },
+ { value: "CNY", label: "CNY (위안)" },
+]
+
+export function QuotationResponseTab({ quotation }: QuotationResponseTabProps) {
+ const [totalPrice, setTotalPrice] = useState(quotation.totalPrice?.toString() || "")
+ const [currency, setCurrency] = useState(quotation.currency || "KRW")
+ const [validUntil, setValidUntil] = useState<Date | undefined>(
+ quotation.validUntil ? new Date(quotation.validUntil) : undefined
+ )
+ const [remark, setRemark] = useState(quotation.remark || "")
+ const [isLoading, setIsLoading] = useState(false)
+ const [attachments, setAttachments] = useState<Array<{
+ id?: number
+ fileName: string
+ fileSize: number
+ filePath: string
+ isNew?: boolean
+ file?: File
+ }>>([])
+ const [isUploadingFiles, setIsUploadingFiles] = useState(false)
+
+ // 견적 히스토리 다이얼로그 상태 관리
+ const [historyDialogOpen, setHistoryDialogOpen] = useState(false)
+
+ const session = useSession()
+
+ // // 초기 첨부파일 데이터 로드
+ // useEffect(() => {
+ // if (quotation.quotationAttachments) {
+ // setAttachments(quotation.quotationAttachments.map(att => ({
+ // id: att.id,
+ // fileName: att.fileName,
+ // fileSize: att.fileSize,
+ // filePath: att.filePath,
+ // isNew: false
+ // })))
+ // }
+ // }, [quotation.quotationAttachments])
+
+ const rfq = quotation.rfq
+ const isDueDatePassed = rfq?.dueDate ? new Date(rfq.dueDate) < new Date() : false
+ const canSubmit = !["Accepted", "Rejected"].includes(quotation.status) && !isDueDatePassed
+ const canEdit = !["Accepted", "Rejected"].includes(quotation.status) && !isDueDatePassed
+
+ // 파일 업로드 핸들러
+ const handleFileSelect = (event: React.ChangeEvent<HTMLInputElement>) => {
+ const files = event.target.files
+ if (!files) return
+
+ Array.from(files).forEach(file => {
+ setAttachments(prev => [
+ ...prev,
+ {
+ fileName: file.name,
+ fileSize: file.size,
+ filePath: '',
+ isNew: true,
+ file
+ }
+ ])
+ })
+ }
+
+ // 첨부파일 제거
+ const removeAttachment = (index: number) => {
+ setAttachments(prev => prev.filter((_, i) => i !== index))
+ }
+
+ // 파일 업로드 함수
+ const uploadFiles = async () => {
+ const newFiles = attachments.filter(att => att.isNew && att.file)
+ if (newFiles.length === 0) return []
+
+ setIsUploadingFiles(true)
+
+ try {
+ // 서비스 함수를 사용하여 파일 업로드
+ const { uploadQuotationAttachments } = await import('@/lib/techsales-rfq/service')
+
+ const files = newFiles.map(att => att.file!).filter(Boolean)
+ const userId = parseInt(session.data?.user.id || "0")
+
+ const result = await uploadQuotationAttachments(quotation.id, files, userId)
+
+ if (result.success && result.attachments) {
+ return result.attachments
+ } else {
+ throw new Error(result.error || '파일 저장에 실패했습니다.')
+ }
+ } catch (error) {
+ console.error('파일 업로드 오류:', error)
+ toast.error('파일 업로드 중 오류가 발생했습니다.')
+ return []
+ } finally {
+ setIsUploadingFiles(false)
+ }
+ }
+
+ const handleSubmit = async () => {
+ if (!totalPrice || !currency || !validUntil) {
+ toast.error("모든 필수 항목을 입력해주세요.")
+ return
+ }
+
+ setIsLoading(true)
+ try {
+ // 파일 업로드 먼저 처리
+ const uploadedFiles = await uploadFiles()
+
+ const { submitTechSalesVendorQuotation } = await import("@/lib/techsales-rfq/service")
+
+ const result = await submitTechSalesVendorQuotation({
+ id: quotation.id,
+ currency,
+ totalPrice,
+ validUntil: validUntil!,
+ remark,
+ attachments: uploadedFiles,
+ updatedBy: parseInt(session.data?.user.id || "0")
+ })
+
+ if (result.error) {
+ toast.error(result.error)
+ } else {
+ toast.success("견적서가 제출되었습니다.")
+ // // 페이지 새로고침 대신 router.refresh() 사용
+ // router.refresh()
+ // 페이지 새로고침
+ window.location.reload()
+ }
+ } catch {
+ toast.error("제출 중 오류가 발생했습니다.")
+ } finally {
+ setIsLoading(false)
+ }
+ }
+
+ const getStatusBadgeVariant = (status: string) => {
+ switch (status) {
+ case "Draft":
+ return "secondary"
+ case "Submitted":
+ return "default"
+ case "Revised":
+ return "outline"
+ case "Rejected":
+ return "destructive"
+ case "Accepted":
+ return "success"
+ default:
+ return "secondary"
+ }
+ }
+
+ const getStatusLabel = (status: string) => {
+ switch (status) {
+ case "Draft":
+ return "초안"
+ case "Submitted":
+ return "제출됨"
+ case "Revised":
+ return "수정됨"
+ case "Rejected":
+ return "반려됨"
+ case "Accepted":
+ return "승인됨"
+ default:
+ return status
+ }
+ }
+
+ return (
+ <ScrollArea className="h-full">
+ <div className="space-y-6 p-1">
+ {/* 견적서 상태 정보 */}
+ <Card>
+ <CardHeader>
+ <div className="flex items-center justify-between">
+ <div>
+ <CardTitle className="flex items-center gap-2">
+ 견적서 상태
+ <Badge variant={getStatusBadgeVariant(quotation.status)}>
+ {getStatusLabel(quotation.status)}
+ </Badge>
+ </CardTitle>
+ <CardDescription>
+ 현재 견적서 상태 및 마감일 정보
+ </CardDescription>
+ </div>
+
+ {/* 견적 히스토리 보기 버튼 */}
+ <Button
+ variant="outline"
+ size="sm"
+ onClick={() => setHistoryDialogOpen(true)}
+ className="flex items-center gap-2"
+ >
+ <History className="h-4 w-4" />
+ 이전 견적 히스토리 보기
+ </Button>
+ </div>
+ </CardHeader>
+ <CardContent className="space-y-4">
+ <div className="grid grid-cols-1 md:grid-cols-3 gap-4">
+ <div className="space-y-2">
+ <div className="text-sm font-medium text-muted-foreground">견적서 상태</div>
+ <div className="text-sm">{getStatusLabel(quotation.status)}</div>
+ </div>
+ <div className="space-y-2">
+ <div className="text-sm font-medium text-muted-foreground">RFQ 마감일</div>
+ <div className="text-sm">
+ {rfq?.dueDate ? formatDate(rfq.dueDate) : "N/A"}
+ </div>
+ </div>
+ <div className="space-y-2">
+ <div className="text-sm font-medium text-muted-foreground">남은 시간</div>
+ <div className="text-sm">
+ {isDueDatePassed ? (
+ <span className="text-destructive">마감됨</span>
+ ) : rfq?.dueDate ? (
+ <span className="text-green-600">
+ {Math.ceil((new Date(rfq.dueDate).getTime() - new Date().getTime()) / (1000 * 60 * 60 * 24))}일
+ </span>
+ ) : (
+ "N/A"
+ )}
+ </div>
+ </div>
+ </div>
+
+ {isDueDatePassed && (
+ <Alert>
+ <AlertCircle className="h-4 w-4" />
+ <AlertDescription>
+ RFQ 마감일이 지났습니다. 견적서를 수정하거나 제출할 수 없습니다.
+ </AlertDescription>
+ </Alert>
+ )}
+
+ {!canEdit && !isDueDatePassed && (
+ <Alert>
+ <AlertCircle className="h-4 w-4" />
+ <AlertDescription>
+ 현재 상태에서는 견적서를 수정할 수 없습니다.
+ </AlertDescription>
+ </Alert>
+ )}
+ </CardContent>
+ </Card>
+
+ {/* 견적 응답 폼 */}
+ <Card>
+ <CardHeader>
+ <CardTitle>견적 응답</CardTitle>
+ <CardDescription>
+ 견적 정보를 입력하고 제출하세요
+ </CardDescription>
+ </CardHeader>
+ <CardContent className="space-y-4">
+ <div className="grid grid-cols-1 md:grid-cols-2 gap-4">
+ <div className="space-y-2">
+ <Label htmlFor="totalPrice">견적 금액 *</Label>
+ <Input
+ id="totalPrice"
+ type="number"
+ value={totalPrice}
+ onChange={(e) => setTotalPrice(e.target.value)}
+ placeholder="견적 금액을 입력하세요"
+ disabled={!canEdit}
+ />
+ </div>
+ <div className="space-y-2">
+ <Label htmlFor="currency">통화 *</Label>
+ <Select value={currency} onValueChange={setCurrency} disabled={!canEdit}>
+ <SelectTrigger>
+ <SelectValue placeholder="통화를 선택하세요" />
+ </SelectTrigger>
+ <SelectContent>
+ {CURRENCIES.map((curr) => (
+ <SelectItem key={curr.value} value={curr.value}>
+ {curr.label}
+ </SelectItem>
+ ))}
+ </SelectContent>
+ </Select>
+ </div>
+ </div>
+
+ <div className="space-y-2">
+ <Label>견적 유효기한 *</Label>
+ <Popover>
+ <PopoverTrigger asChild>
+ <Button
+ variant="outline"
+ className={cn(
+ "w-full justify-start text-left font-normal",
+ !validUntil && "text-muted-foreground"
+ )}
+ disabled={!canEdit}
+ >
+ <CalendarIcon className="mr-2 h-4 w-4" />
+ {validUntil ? formatDate(validUntil) : "유효기한을 선택하세요"}
+ </Button>
+ </PopoverTrigger>
+ <PopoverContent className="w-auto p-0">
+ <Calendar
+ mode="single"
+ selected={validUntil}
+ onSelect={setValidUntil}
+ initialFocus
+ />
+ </PopoverContent>
+ </Popover>
+ </div>
+
+ <div className="space-y-2">
+ <Label htmlFor="remark">비고</Label>
+ <Textarea
+ id="remark"
+ value={remark}
+ onChange={(e) => setRemark(e.target.value)}
+ placeholder="추가 설명이나 특이사항을 입력하세요"
+ rows={3}
+ disabled={!canEdit}
+ />
+ </div>
+
+ {/* 첨부파일 섹션 */}
+ <div className="space-y-2">
+ <Label>첨부파일</Label>
+ <div className="border-2 border-dashed border-gray-300 rounded-lg p-4">
+ <input
+ type="file"
+ id="file-upload"
+ multiple
+ onChange={handleFileSelect}
+ className="hidden"
+ disabled={!canEdit}
+ />
+ <div className="text-center">
+ <FileText className="mx-auto h-12 w-12 text-gray-400" />
+ <div className="mt-2">
+ <Label htmlFor="file-upload" className="cursor-pointer">
+ <span className="text-sm font-medium text-blue-600 hover:text-blue-500">
+ 파일을 선택하세요
+ </span>
+ </Label>
+ </div>
+ <p className="text-xs text-gray-500 mt-1">
+ PDF, DOC, DOCX, XLS, XLSX, PNG, JPG 등
+ </p>
+ </div>
+ </div>
+
+ {/* 첨부파일 목록 */}
+ {attachments.length > 0 && (
+ <div className="space-y-2">
+ <Label>첨부된 파일</Label>
+ <div className="space-y-2">
+ {attachments.map((attachment, index) => (
+ <div key={index} className="flex items-center justify-between p-2 bg-gray-50 rounded">
+ <div className="flex items-center gap-2">
+ <FileIcon className="h-4 w-4 text-gray-500" />
+ <span className="text-sm">{attachment.fileName}</span>
+ <span className="text-xs text-gray-500">
+ ({(attachment.fileSize / 1024).toFixed(1)} KB)
+ </span>
+ </div>
+ <div className="flex items-center gap-2">
+ {!attachment.isNew && (
+ <Button
+ variant="ghost"
+ size="sm"
+ onClick={() => window.open(attachment.filePath, '_blank')}
+ >
+ <Download className="h-4 w-4" />
+ </Button>
+ )}
+ {canEdit && (
+ <Button
+ variant="ghost"
+ size="sm"
+ onClick={() => removeAttachment(index)}
+ >
+ <X className="h-4 w-4" />
+ </Button>
+ )}
+ </div>
+ </div>
+ ))}
+ </div>
+ </div>
+ )}
+ </div>
+
+ {/* 제출 버튼 */}
+ <div className="flex justify-end">
+ <Button
+ onClick={handleSubmit}
+ disabled={!canSubmit || isLoading || isUploadingFiles}
+ className="flex items-center gap-2"
+ >
+ {isLoading ? (
+ <>
+ <div className="animate-spin rounded-full h-4 w-4 border-b-2 border-white" />
+ 제출 중...
+ </>
+ ) : (
+ <>
+ <Send className="h-4 w-4" />
+ 견적서 제출
+ </>
+ )}
+ </Button>
+ </div>
+ </CardContent>
+ </Card>
+
+ {/* 현재 견적 정보 (읽기 전용) */}
+ {quotation.totalPrice && (
+ <Card>
+ <CardHeader>
+ <CardTitle>현재 견적 정보</CardTitle>
+ <CardDescription>
+ 저장된 견적 정보
+ </CardDescription>
+ </CardHeader>
+ <CardContent className="space-y-4">
+ <div className="grid grid-cols-1 md:grid-cols-3 gap-4">
+ <div className="space-y-2">
+ <div className="text-sm font-medium text-muted-foreground">총 가격</div>
+ <div className="text-lg font-semibold">
+ {parseFloat(quotation.totalPrice).toLocaleString()} {quotation.currency}
+ </div>
+ </div>
+ <div className="space-y-2">
+ <div className="text-sm font-medium text-muted-foreground">통화</div>
+ <div className="text-sm">{quotation.currency}</div>
+ </div>
+ <div className="space-y-2">
+ <div className="text-sm font-medium text-muted-foreground">유효기간</div>
+ <div className="text-sm">
+ {quotation.validUntil ? formatDate(quotation.validUntil) : "N/A"}
+ </div>
+ </div>
+ </div>
+ {quotation.remark && (
+ <div className="space-y-2">
+ <div className="text-sm font-medium text-muted-foreground">비고</div>
+ <div className="text-sm p-3 bg-muted rounded-md">{quotation.remark}</div>
+ </div>
+ )}
+ </CardContent>
+ </Card>
+ )}
+ </div>
+
+ {/* 견적 히스토리 다이얼로그 */}
+ <QuotationHistoryDialog
+ open={historyDialogOpen}
+ onOpenChange={setHistoryDialogOpen}
+ quotationId={quotation.id}
+ />
+ </ScrollArea>
+ )
} \ No newline at end of file
diff --git a/lib/techsales-rfq/vendor-response/detail/quotation-tabs.tsx b/lib/techsales-rfq/vendor-response/detail/quotation-tabs.tsx
index 2e2f5d70..7af50b24 100644
--- a/lib/techsales-rfq/vendor-response/detail/quotation-tabs.tsx
+++ b/lib/techsales-rfq/vendor-response/detail/quotation-tabs.tsx
@@ -1,84 +1,84 @@
-"use client"
-
-import * as React from "react"
-import { useRouter, useSearchParams } from "next/navigation"
-import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
-import { ProjectInfoTab } from "./project-info-tab"
-import { QuotationResponseTab } from "./quotation-response-tab"
-import { CommunicationTab } from "./communication-tab"
-
-interface QuotationData {
- id: number
- status: string
- totalPrice: string | null
- currency: string | null
- validUntil: Date | null
- remark: string | null
- rfq: {
- id: number
- rfqCode: string | null
- materialCode: string | null
- dueDate: Date | null
- status: string | null
- remark: string | null
- biddingProject?: {
- id: number
- pspid: string | null
- projNm: string | null
- sector: string | null
- projMsrm: string | null
- ptypeNm: string | null
- } | null
- createdByUser?: {
- id: number
- name: string | null
- email: string | null
- } | null
- } | null
- vendor: {
- id: number
- vendorName: string
- vendorCode: string | null
- } | null
-}
-
-interface TechSalesQuotationTabsProps {
- quotation: QuotationData
- defaultTab?: string
-}
-
-export function TechSalesQuotationTabs({ quotation, defaultTab = "project" }: TechSalesQuotationTabsProps) {
- const router = useRouter()
- const searchParams = useSearchParams()
- const currentTab = searchParams?.get("tab") || defaultTab
-
- const handleTabChange = (value: string) => {
- const params = new URLSearchParams(searchParams?.toString() || "")
- params.set("tab", value)
- router.push(`?${params.toString()}`, { scroll: false })
- }
-
- return (
- <Tabs value={currentTab} onValueChange={handleTabChange} className="h-full flex flex-col">
- <TabsList className="grid w-full grid-cols-3">
- <TabsTrigger value="project">프로젝트 및 RFQ 정보</TabsTrigger>
- <TabsTrigger value="quotation">견적 응답</TabsTrigger>
- <TabsTrigger value="communication">커뮤니케이션</TabsTrigger>
- </TabsList>
-
- <div className="flex-1 mt-4 overflow-hidden">
- <TabsContent value="project" className="h-full m-0">
- <ProjectInfoTab quotation={quotation} />
- </TabsContent>
-
- <TabsContent value="quotation" className="h-full m-0">
- <QuotationResponseTab quotation={quotation} />
- </TabsContent>
-
- <TabsContent value="communication" className="h-full m-0">
- <CommunicationTab quotation={quotation} />
- </TabsContent>
- </div>
- </Tabs>
- )
+"use client"
+
+import * as React from "react"
+import { useRouter, useSearchParams } from "next/navigation"
+import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
+import { ProjectInfoTab } from "./project-info-tab"
+import { QuotationResponseTab } from "./quotation-response-tab"
+import { CommunicationTab } from "./communication-tab"
+
+interface QuotationData {
+ id: number
+ status: string
+ totalPrice: string | null
+ currency: string | null
+ validUntil: Date | null
+ remark: string | null
+ rfq: {
+ id: number
+ rfqCode: string | null
+ materialCode: string | null
+ dueDate: Date | null
+ status: string | null
+ remark: string | null
+ biddingProject?: {
+ id: number
+ pspid: string | null
+ projNm: string | null
+ sector: string | null
+ projMsrm: string | null
+ ptypeNm: string | null
+ } | null
+ createdByUser?: {
+ id: number
+ name: string | null
+ email: string | null
+ } | null
+ } | null
+ vendor: {
+ id: number
+ vendorName: string
+ vendorCode: string | null
+ } | null
+}
+
+interface TechSalesQuotationTabsProps {
+ quotation: QuotationData
+ defaultTab?: string
+}
+
+export function TechSalesQuotationTabs({ quotation, defaultTab = "project" }: TechSalesQuotationTabsProps) {
+ const router = useRouter()
+ const searchParams = useSearchParams()
+ const currentTab = searchParams?.get("tab") || defaultTab
+
+ const handleTabChange = (value: string) => {
+ const params = new URLSearchParams(searchParams?.toString() || "")
+ params.set("tab", value)
+ router.push(`?${params.toString()}`, { scroll: false })
+ }
+
+ return (
+ <Tabs value={currentTab} onValueChange={handleTabChange} className="h-full flex flex-col">
+ <TabsList className="grid w-full grid-cols-3">
+ <TabsTrigger value="project">프로젝트 및 RFQ 정보</TabsTrigger>
+ <TabsTrigger value="quotation">견적 응답</TabsTrigger>
+ <TabsTrigger value="communication">커뮤니케이션</TabsTrigger>
+ </TabsList>
+
+ <div className="flex-1 mt-4 overflow-hidden">
+ <TabsContent value="project" className="h-full m-0">
+ <ProjectInfoTab quotation={quotation} />
+ </TabsContent>
+
+ <TabsContent value="quotation" className="h-full m-0">
+ <QuotationResponseTab quotation={quotation} />
+ </TabsContent>
+
+ <TabsContent value="communication" className="h-full m-0">
+ <CommunicationTab quotation={quotation} />
+ </TabsContent>
+ </div>
+ </Tabs>
+ )
} \ No newline at end of file
diff --git a/lib/techsales-rfq/vendor-response/table/vendor-quotations-table-columns.tsx b/lib/techsales-rfq/vendor-response/table/vendor-quotations-table-columns.tsx
index 39de94ed..328def80 100644
--- a/lib/techsales-rfq/vendor-response/table/vendor-quotations-table-columns.tsx
+++ b/lib/techsales-rfq/vendor-response/table/vendor-quotations-table-columns.tsx
@@ -1,672 +1,710 @@
-"use client"
-
-import * as React from "react"
-import { type ColumnDef } from "@tanstack/react-table"
-import { Edit, Paperclip, Package } from "lucide-react"
-import { formatCurrency, formatDate, formatDateTime } from "@/lib/utils"
-import { Badge } from "@/components/ui/badge"
-import { Button } from "@/components/ui/button"
-import { Checkbox } from "@/components/ui/checkbox"
-import {
- Tooltip,
- TooltipContent,
- TooltipProvider,
- TooltipTrigger,
-} from "@/components/ui/tooltip"
-import {
- TechSalesVendorQuotations,
- TECH_SALES_QUOTATION_STATUS_CONFIG,
- TECH_SALES_QUOTATION_STATUSES
-} from "@/db/schema"
-import { AppRouterInstance } from "next/dist/shared/lib/app-router-context.shared-runtime"
-import { DataTableColumnHeaderSimple } from "@/components/data-table/data-table-column-simple-header"
-
-interface QuotationWithRfqCode extends TechSalesVendorQuotations {
- // RFQ 관련 정보
- rfqCode?: string;
- materialCode?: string;
- dueDate?: Date;
- rfqStatus?: string;
-
- // 아이템 정보
- itemName?: string;
- itemCount?: number;
-
- // 프로젝트 정보
- projNm?: string;
- pspid?: string;
- sector?: string;
-
- // RFQ 정보
- description?: string;
-
- // 벤더 정보
- vendorName?: string;
- vendorCode?: string;
-
- // 사용자 정보
- createdByName?: string | null;
- updatedByName?: string | null;
-
- // 첨부파일 개수
- attachmentCount?: number;
-}
-
-interface GetColumnsProps {
- router: AppRouterInstance;
- openAttachmentsSheet: (rfqId: number) => void;
- openItemsDialog: (rfq: { id: number; rfqCode?: string; status?: string; rfqType?: "SHIP" | "TOP" | "HULL"; }) => void;
-}
-
-export function getColumns({ router, openAttachmentsSheet, openItemsDialog }: GetColumnsProps): ColumnDef<QuotationWithRfqCode>[] {
- return [
- {
- id: "select",
- header: ({ table }) => (
- <Checkbox
- checked={
- table.getIsAllPageRowsSelected() ||
- (table.getIsSomePageRowsSelected() && "indeterminate")
- }
- onCheckedChange={(value) => table.toggleAllPageRowsSelected(!!value)}
- aria-label="모두 선택"
- className="translate-y-0.5"
- />
- ),
- cell: ({ row }) => {
- const isRejected = row.original.status === TECH_SALES_QUOTATION_STATUSES.REJECTED;
- const isAccepted = row.original.status === TECH_SALES_QUOTATION_STATUSES.ACCEPTED;
- const isDisabled = isRejected || isAccepted;
-
- return (
- <Checkbox
- checked={row.getIsSelected()}
- onCheckedChange={(value) => row.toggleSelected(!!value)}
- aria-label="행 선택"
- className="translate-y-0.5"
- disabled={isDisabled}
- />
- );
- },
- enableSorting: false,
- enableHiding: false,
- },
- // {
- // accessorKey: "id",
- // header: ({ column }) => (
- // <DataTableColumnHeaderSimple column={column} title="ID" />
- // ),
- // cell: ({ row }) => (
- // <div className="w-20">
- // <span className="font-mono text-xs">{row.getValue("id")}</span>
- // </div>
- // ),
- // enableSorting: true,
- // enableHiding: true,
- // },
- {
- accessorKey: "rfqCode",
- header: ({ column }) => (
- <DataTableColumnHeaderSimple column={column} title="RFQ 번호" />
- ),
- cell: ({ row }) => {
- const rfqCode = row.getValue("rfqCode") as string;
- return (
- <div className="min-w-32">
- <span className="font-mono text-sm">{rfqCode || "N/A"}</span>
- </div>
- );
- },
- enableSorting: true,
- enableHiding: false,
- },
- // {
- // accessorKey: "vendorName",
- // header: ({ column }) => (
- // <DataTableColumnHeaderSimple column={column} title="벤더명" />
- // ),
- // cell: ({ row }) => {
- // const vendorName = row.getValue("vendorName") as string;
- // return (
- // <div className="min-w-32">
- // <span className="text-sm">{vendorName || "N/A"}</span>
- // </div>
- // );
- // },
- // enableSorting: true,
- // enableHiding: false,
- // },
- // {
- // accessorKey: "vendorCode",
- // header: ({ column }) => (
- // <DataTableColumnHeaderSimple column={column} title="벤더 코드" />
- // ),
- // cell: ({ row }) => {
- // const vendorCode = row.getValue("vendorCode") as string;
- // return (
- // <div className="min-w-24">
- // <span className="font-mono text-sm">{vendorCode || "N/A"}</span>
- // </div>
- // );
- // },
- // enableSorting: true,
- // enableHiding: true,
- // },
- // {
- // accessorKey: "materialCode",
- // header: ({ column }) => (
- // <DataTableColumnHeaderSimple column={column} title="자재 그룹" />
- // ),
- // cell: ({ row }) => {
- // const materialCode = row.getValue("materialCode") as string;
- // return (
- // <div className="min-w-32">
- // <span className="font-mono text-sm">{materialCode || "N/A"}</span>
- // </div>
- // );
- // },
- // enableSorting: true,
- // enableHiding: true,
- // },
- // {
- // accessorKey: "itemName",
- // header: ({ column }) => (
- // <DataTableColumnHeaderSimple column={column} title="자재명" />
- // ),
- // cell: ({ row }) => {
- // const itemName = row.getValue("itemName") as string;
- // return (
- // <div className="min-w-48 max-w-64">
- // <TooltipProvider>
- // <Tooltip>
- // <TooltipTrigger asChild>
- // <span className="truncate block text-sm">
- // {itemName || "N/A"}
- // </span>
- // </TooltipTrigger>
- // <TooltipContent>
- // <p className="max-w-xs">{itemName || "N/A"}</p>
- // </TooltipContent>
- // </Tooltip>
- // </TooltipProvider>
- // </div>
- // );
- // },
- // enableSorting: true,
- // enableHiding: true,
- // },
- {
- accessorKey: "description",
- header: ({ column }) => (
- <DataTableColumnHeaderSimple column={column} title="RFQ title" />
- ),
- cell: ({ row }) => {
- const description = row.getValue("description") as string;
- return (
- <div className="min-w-48 max-w-64">
- <TooltipProvider>
- <Tooltip>
- <TooltipTrigger asChild>
- <span className="truncate block text-sm">
- {description || "N/A"}
- </span>
- </TooltipTrigger>
- <TooltipContent>
- <p className="max-w-xs">{description || "N/A"}</p>
- </TooltipContent>
- </Tooltip>
- </TooltipProvider>
- </div>
- );
- },
- enableSorting: true,
- enableHiding: true,
- },
- {
- accessorKey: "projNm",
- header: ({ column }) => (
- <DataTableColumnHeaderSimple column={column} title="프로젝트명" />
- ),
- cell: ({ row }) => {
- const projNm = row.getValue("projNm") as string;
- return (
- <div className="min-w-48 max-w-64">
- <TooltipProvider>
- <Tooltip>
- <TooltipTrigger asChild>
- <span className="truncate block text-sm">
- {projNm || "N/A"}
- </span>
- </TooltipTrigger>
- <TooltipContent>
- <p className="max-w-xs">{projNm || "N/A"}</p>
- </TooltipContent>
- </Tooltip>
- </TooltipProvider>
- </div>
- );
- },
- enableSorting: true,
- enableHiding: true,
- },
- // {
- // accessorKey: "quotationCode",
- // header: ({ column }) => (
- // <DataTableColumnHeaderSimple column={column} title="견적서 번호" />
- // ),
- // cell: ({ row }) => {
- // const quotationCode = row.getValue("quotationCode") as string;
- // return (
- // <div className="min-w-32">
- // <span className="font-mono text-sm">{quotationCode || "미부여"}</span>
- // </div>
- // );
- // },
- // enableSorting: true,
- // enableHiding: true,
- // },
- // {
- // accessorKey: "quotationVersion",
- // header: ({ column }) => (
- // <DataTableColumnHeaderSimple column={column} title="버전" />
- // ),
- // cell: ({ row }) => {
- // const quotationVersion = row.getValue("quotationVersion") as number;
- // return (
- // <div className="w-16 text-center">
- // <span className="text-sm">{quotationVersion || 1}</span>
- // </div>
- // );
- // },
- // enableSorting: true,
- // enableHiding: true,
- // },
- {
- id: "items",
- header: ({ column }) => (
- <DataTableColumnHeaderSimple column={column} title="아이템" />
- ),
- cell: ({ row }) => {
- const quotation = row.original
- const itemCount = quotation.itemCount || 0
-
- const handleClick = () => {
- const rfq = {
- id: quotation.rfqId,
- rfqCode: quotation.rfqCode,
- status: quotation.rfqStatus,
- rfqType: "SHIP" as const, // 기본값
- }
- openItemsDialog(rfq)
- }
-
- return (
- <div className="w-20">
- <TooltipProvider>
- <Tooltip>
- <TooltipTrigger asChild>
- <Button
- variant="ghost"
- size="sm"
- className="relative h-8 w-8 p-0 group"
- onClick={handleClick}
- aria-label={`View ${itemCount} items`}
- >
- <Package className="h-4 w-4 text-muted-foreground group-hover:text-primary transition-colors" />
- {itemCount > 0 && (
- <span className="pointer-events-none absolute -top-1 -right-1 inline-flex h-4 min-w-[1rem] items-center justify-center rounded-full bg-primary px-1 text-[0.625rem] font-medium leading-none text-primary-foreground">
- {itemCount}
- </span>
- )}
- <span className="sr-only">
- {itemCount > 0 ? `${itemCount} 아이템` : "아이템 없음"}
- </span>
- </Button>
- </TooltipTrigger>
- <TooltipContent>
- <p>{itemCount > 0 ? `${itemCount}개 아이템 보기` : "아이템 없음"}</p>
- </TooltipContent>
- </Tooltip>
- </TooltipProvider>
- </div>
- )
- },
- enableSorting: false,
- enableHiding: true,
- },
- {
- id: "attachments",
- header: ({ column }) => (
- <DataTableColumnHeaderSimple column={column} title="첨부파일" />
- ),
- cell: ({ row }) => {
- const quotation = row.original
- const attachmentCount = quotation.attachmentCount || 0
- const handleClick = () => {
- openAttachmentsSheet(quotation.rfqId)
- }
-
- return (
- <div className="w-20">
- <TooltipProvider>
- <Tooltip>
- <TooltipTrigger asChild>
- <Button
- variant="ghost"
- size="sm"
- className="relative h-8 w-8 p-0 group"
- onClick={handleClick}
- aria-label={
- attachmentCount > 0 ? `View ${attachmentCount} attachments` : "No attachments"
- }
- >
- <Paperclip className="h-4 w-4 text-muted-foreground group-hover:text-primary transition-colors" />
- {attachmentCount > 0 && (
- <span className="pointer-events-none absolute -top-1 -right-1 inline-flex h-4 min-w-[1rem] items-center justify-center rounded-full bg-primary px-1 text-[0.625rem] font-medium leading-none text-primary-foreground">
- {attachmentCount}
- </span>
- )}
- <span className="sr-only">
- {attachmentCount > 0 ? `${attachmentCount} 첨부파일` : "첨부파일 없음"}
- </span>
- </Button>
- </TooltipTrigger>
- <TooltipContent>
- <p>{attachmentCount > 0 ? `${attachmentCount}개 첨부파일 보기` : "첨부파일 없음"}</p>
- </TooltipContent>
- </Tooltip>
- </TooltipProvider>
- </div>
- )
- },
- enableSorting: false,
- enableHiding: true,
- },
- {
- accessorKey: "status",
- header: ({ column }) => (
- <DataTableColumnHeaderSimple column={column} title="상태" />
- ),
- cell: ({ row }) => {
- const status = row.getValue("status") as string;
-
- const statusConfig = TECH_SALES_QUOTATION_STATUS_CONFIG[status as keyof typeof TECH_SALES_QUOTATION_STATUS_CONFIG] || {
- label: status,
- variant: "secondary" as const
- };
-
- return (
- <div className="w-24">
- <Badge variant={statusConfig.variant} className="text-xs">
- {statusConfig.label}
- </Badge>
- </div>
- );
- },
- enableSorting: true,
- enableHiding: false,
- filterFn: (row, id, value) => {
- return value.includes(row.getValue(id));
- },
- },
- {
- accessorKey: "currency",
- header: ({ column }) => (
- <DataTableColumnHeaderSimple column={column} title="통화" />
- ),
- cell: ({ row }) => {
- const currency = row.getValue("currency") as string;
- return (
- <div className="w-16">
- <span className="font-mono text-sm">{currency || "N/A"}</span>
- </div>
- );
- },
- enableSorting: true,
- enableHiding: true,
- },
- {
- accessorKey: "totalPrice",
- header: ({ column }) => (
- <DataTableColumnHeaderSimple column={column} title="총액" />
- ),
- cell: ({ row }) => {
- const totalPrice = row.getValue("totalPrice") as string;
- const currency = row.getValue("currency") as string;
-
- if (!totalPrice || totalPrice === "0") {
- return (
- <div className="w-32 text-right">
- <span className="text-muted-foreground text-sm">미입력</span>
- </div>
- );
- }
-
- return (
- <div className="w-32 text-right">
- <span className="font-mono text-sm">
- {formatCurrency(parseFloat(totalPrice), currency || "USD")}
- </span>
- </div>
- );
- },
- enableSorting: true,
- enableHiding: true,
- },
- {
- accessorKey: "validUntil",
- header: ({ column }) => (
- <DataTableColumnHeaderSimple column={column} title="유효기간" />
- ),
- cell: ({ row }) => {
- const validUntil = row.getValue("validUntil") as Date;
- return (
- <div className="w-28">
- <span className="text-sm">
- {validUntil ? formatDate(validUntil) : "N/A"}
- </span>
- </div>
- );
- },
- enableSorting: true,
- enableHiding: true,
- },
- {
- accessorKey: "submittedAt",
- header: ({ column }) => (
- <DataTableColumnHeaderSimple column={column} title="제출일" />
- ),
- cell: ({ row }) => {
- const submittedAt = row.getValue("submittedAt") as Date;
- return (
- <div className="w-36">
- <span className="text-sm">
- {submittedAt ? formatDateTime(submittedAt) : "미제출"}
- </span>
- </div>
- );
- },
- enableSorting: true,
- enableHiding: true,
- },
- // {
- // accessorKey: "acceptedAt",
- // header: ({ column }) => (
- // <DataTableColumnHeaderSimple column={column} title="승인일" />
- // ),
- // cell: ({ row }) => {
- // const acceptedAt = row.getValue("acceptedAt") as Date;
- // return (
- // <div className="w-36">
- // <span className="text-sm">
- // {acceptedAt ? formatDateTime(acceptedAt) : "미승인"}
- // </span>
- // </div>
- // );
- // },
- // enableSorting: true,
- // enableHiding: true,
- // },
- {
- accessorKey: "dueDate",
- header: ({ column }) => (
- <DataTableColumnHeaderSimple column={column} title="마감일" />
- ),
- cell: ({ row }) => {
- const dueDate = row.getValue("dueDate") as Date;
- const isOverdue = dueDate && new Date() > new Date(dueDate);
-
- return (
- <div className="w-28">
- <span className={`text-sm ${isOverdue ? "text-red-600 font-medium" : ""}`}>
- {dueDate ? formatDate(dueDate) : "N/A"}
- </span>
- </div>
- );
- },
- enableSorting: true,
- enableHiding: true,
- },
- // {
- // accessorKey: "rejectionReason",
- // header: ({ column }) => (
- // <DataTableColumnHeaderSimple column={column} title="반려사유" />
- // ),
- // cell: ({ row }) => {
- // const rejectionReason = row.getValue("rejectionReason") as string;
- // return (
- // <div className="min-w-48 max-w-64">
- // {rejectionReason ? (
- // <TooltipProvider>
- // <Tooltip>
- // <TooltipTrigger asChild>
- // <span className="truncate block text-sm text-red-600">
- // {rejectionReason}
- // </span>
- // </TooltipTrigger>
- // <TooltipContent>
- // <p className="max-w-xs">{rejectionReason}</p>
- // </TooltipContent>
- // </Tooltip>
- // </TooltipProvider>
- // ) : (
- // <span className="text-sm text-muted-foreground">N/A</span>
- // )}
- // </div>
- // );
- // },
- // enableSorting: false,
- // enableHiding: true,
- // },
- {
- accessorKey: "createdAt",
- header: ({ column }) => (
- <DataTableColumnHeaderSimple column={column} title="생성일" />
- ),
- cell: ({ row }) => {
- const createdAt = row.getValue("createdAt") as Date;
- return (
- <div className="w-36">
- <span className="text-sm">
- {createdAt ? formatDateTime(createdAt) : "N/A"}
- </span>
- </div>
- );
- },
- enableSorting: true,
- enableHiding: true,
- },
- {
- accessorKey: "updatedAt",
- header: ({ column }) => (
- <DataTableColumnHeaderSimple column={column} title="수정일" />
- ),
- cell: ({ row }) => {
- const updatedAt = row.getValue("updatedAt") as Date;
- return (
- <div className="w-36">
- <span className="text-sm">
- {updatedAt ? formatDateTime(updatedAt) : "N/A"}
- </span>
- </div>
- );
- },
- enableSorting: true,
- enableHiding: true,
- },
- // {
- // accessorKey: "createdByName",
- // header: ({ column }) => (
- // <DataTableColumnHeaderSimple column={column} title="생성자" />
- // ),
- // cell: ({ row }) => {
- // const createdByName = row.getValue("createdByName") as string;
- // return (
- // <div className="w-24">
- // <span className="text-sm">{createdByName || "N/A"}</span>
- // </div>
- // );
- // },
- // enableSorting: true,
- // enableHiding: true,
- // },
- // {
- // accessorKey: "updatedByName",
- // header: ({ column }) => (
- // <DataTableColumnHeaderSimple column={column} title="수정자" />
- // ),
- // cell: ({ row }) => {
- // const updatedByName = row.getValue("updatedByName") as string;
- // return (
- // <div className="w-24">
- // <span className="text-sm">{updatedByName || "N/A"}</span>
- // </div>
- // );
- // },
- // enableSorting: true,
- // enableHiding: true,
- // },
- {
- id: "actions",
- header: "작업",
- cell: ({ row }) => {
- const quotation = row.original;
- const rfqCode = quotation.rfqCode || "N/A";
- const tooltipText = `${rfqCode} 견적서 작성`;
- const isRejected = quotation.status === "Rejected";
- const isAccepted = quotation.status === "Accepted";
- const isDisabled = isRejected || isAccepted;
-
- return (
- <div className="w-16">
- <TooltipProvider>
- <Tooltip>
- <TooltipTrigger asChild>
- <Button
- variant="ghost"
- size="icon"
- onClick={() => {
- if (!isDisabled) {
- router.push(`/ko/partners/techsales/rfq-ship/${quotation.id}`);
- }
- }}
- className="h-8 w-8"
- disabled={isDisabled}
- >
- <Edit className="h-4 w-4" />
- <span className="sr-only">견적서 작성</span>
- </Button>
- </TooltipTrigger>
- <TooltipContent>
- <p>{isRejected ? "거절된 견적서는 편집할 수 없습니다" : isAccepted ? "승인된 견적서는 편집할 수 없습니다" : tooltipText}</p>
- </TooltipContent>
- </Tooltip>
- </TooltipProvider>
- </div>
- );
- },
- enableSorting: false,
- enableHiding: false,
- },
- ];
+"use client"
+
+import * as React from "react"
+import { type ColumnDef } from "@tanstack/react-table"
+import { Edit, Paperclip, Package, Users } from "lucide-react"
+import { formatCurrency, formatDate, formatDateTime } from "@/lib/utils"
+import { Badge } from "@/components/ui/badge"
+import { Button } from "@/components/ui/button"
+import { Checkbox } from "@/components/ui/checkbox"
+import {
+ Tooltip,
+ TooltipContent,
+ TooltipProvider,
+ TooltipTrigger,
+} from "@/components/ui/tooltip"
+import {
+ TechSalesVendorQuotations,
+ TECH_SALES_QUOTATION_STATUS_CONFIG,
+ TECH_SALES_QUOTATION_STATUSES
+} from "@/db/schema"
+import { AppRouterInstance } from "next/dist/shared/lib/app-router-context.shared-runtime"
+import { DataTableColumnHeaderSimple } from "@/components/data-table/data-table-column-simple-header"
+
+interface QuotationWithRfqCode extends TechSalesVendorQuotations {
+ // RFQ 관련 정보
+ rfqCode?: string;
+ materialCode?: string;
+ dueDate?: Date;
+ rfqStatus?: string;
+
+ // 아이템 정보
+ itemName?: string;
+ itemCount?: number;
+
+ // 프로젝트 정보
+ projNm?: string;
+ pspid?: string;
+ sector?: string;
+
+ // RFQ 정보
+ description?: string;
+
+ // 벤더 정보
+ vendorName?: string;
+ vendorCode?: string;
+
+ // 사용자 정보
+ createdByName?: string | null;
+ updatedByName?: string | null;
+
+ // 첨부파일 개수
+ attachmentCount?: number;
+}
+
+interface GetColumnsProps {
+ router: AppRouterInstance;
+ openAttachmentsSheet: (rfqId: number) => void;
+ openItemsDialog: (rfq: { id: number; rfqCode?: string; status?: string; rfqType?: "SHIP" | "TOP" | "HULL"; }) => void;
+ openContactsDialog: (quotationId: number, vendorName?: string) => void;
+}
+
+export function getColumns({ router, openAttachmentsSheet, openItemsDialog, openContactsDialog }: GetColumnsProps): ColumnDef<QuotationWithRfqCode>[] {
+ return [
+ {
+ id: "select",
+ header: ({ table }) => (
+ <Checkbox
+ checked={
+ table.getIsAllPageRowsSelected() ||
+ (table.getIsSomePageRowsSelected() && "indeterminate")
+ }
+ onCheckedChange={(value) => table.toggleAllPageRowsSelected(!!value)}
+ aria-label="모두 선택"
+ className="translate-y-0.5"
+ />
+ ),
+ cell: ({ row }) => {
+ const isRejected = row.original.status === TECH_SALES_QUOTATION_STATUSES.REJECTED;
+ const isAccepted = row.original.status === TECH_SALES_QUOTATION_STATUSES.ACCEPTED;
+ const isDisabled = isRejected || isAccepted;
+
+ return (
+ <Checkbox
+ checked={row.getIsSelected()}
+ onCheckedChange={(value) => row.toggleSelected(!!value)}
+ aria-label="행 선택"
+ className="translate-y-0.5"
+ disabled={isDisabled}
+ />
+ );
+ },
+ enableSorting: false,
+ enableHiding: false,
+ },
+ // {
+ // accessorKey: "id",
+ // header: ({ column }) => (
+ // <DataTableColumnHeaderSimple column={column} title="ID" />
+ // ),
+ // cell: ({ row }) => (
+ // <div className="w-20">
+ // <span className="font-mono text-xs">{row.getValue("id")}</span>
+ // </div>
+ // ),
+ // enableSorting: true,
+ // enableHiding: true,
+ // },
+ {
+ accessorKey: "rfqCode",
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="RFQ 번호" />
+ ),
+ cell: ({ row }) => {
+ const rfqCode = row.getValue("rfqCode") as string;
+ return (
+ <div className="min-w-32">
+ <span className="font-mono text-sm">{rfqCode || "N/A"}</span>
+ </div>
+ );
+ },
+ enableSorting: true,
+ enableHiding: false,
+ },
+ // {
+ // accessorKey: "vendorName",
+ // header: ({ column }) => (
+ // <DataTableColumnHeaderSimple column={column} title="벤더명" />
+ // ),
+ // cell: ({ row }) => {
+ // const vendorName = row.getValue("vendorName") as string;
+ // return (
+ // <div className="min-w-32">
+ // <span className="text-sm">{vendorName || "N/A"}</span>
+ // </div>
+ // );
+ // },
+ // enableSorting: true,
+ // enableHiding: false,
+ // },
+ // {
+ // accessorKey: "vendorCode",
+ // header: ({ column }) => (
+ // <DataTableColumnHeaderSimple column={column} title="벤더 코드" />
+ // ),
+ // cell: ({ row }) => {
+ // const vendorCode = row.getValue("vendorCode") as string;
+ // return (
+ // <div className="min-w-24">
+ // <span className="font-mono text-sm">{vendorCode || "N/A"}</span>
+ // </div>
+ // );
+ // },
+ // enableSorting: true,
+ // enableHiding: true,
+ // },
+ // {
+ // accessorKey: "materialCode",
+ // header: ({ column }) => (
+ // <DataTableColumnHeaderSimple column={column} title="자재 그룹" />
+ // ),
+ // cell: ({ row }) => {
+ // const materialCode = row.getValue("materialCode") as string;
+ // return (
+ // <div className="min-w-32">
+ // <span className="font-mono text-sm">{materialCode || "N/A"}</span>
+ // </div>
+ // );
+ // },
+ // enableSorting: true,
+ // enableHiding: true,
+ // },
+ // {
+ // accessorKey: "itemName",
+ // header: ({ column }) => (
+ // <DataTableColumnHeaderSimple column={column} title="자재명" />
+ // ),
+ // cell: ({ row }) => {
+ // const itemName = row.getValue("itemName") as string;
+ // return (
+ // <div className="min-w-48 max-w-64">
+ // <TooltipProvider>
+ // <Tooltip>
+ // <TooltipTrigger asChild>
+ // <span className="truncate block text-sm">
+ // {itemName || "N/A"}
+ // </span>
+ // </TooltipTrigger>
+ // <TooltipContent>
+ // <p className="max-w-xs">{itemName || "N/A"}</p>
+ // </TooltipContent>
+ // </Tooltip>
+ // </TooltipProvider>
+ // </div>
+ // );
+ // },
+ // enableSorting: true,
+ // enableHiding: true,
+ // },
+ {
+ accessorKey: "description",
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="RFQ title" />
+ ),
+ cell: ({ row }) => {
+ const description = row.getValue("description") as string;
+ return (
+ <div className="min-w-48 max-w-64">
+ <TooltipProvider>
+ <Tooltip>
+ <TooltipTrigger asChild>
+ <span className="truncate block text-sm">
+ {description || "N/A"}
+ </span>
+ </TooltipTrigger>
+ <TooltipContent>
+ <p className="max-w-xs">{description || "N/A"}</p>
+ </TooltipContent>
+ </Tooltip>
+ </TooltipProvider>
+ </div>
+ );
+ },
+ enableSorting: true,
+ enableHiding: true,
+ },
+ {
+ accessorKey: "projNm",
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="프로젝트명" />
+ ),
+ cell: ({ row }) => {
+ const projNm = row.getValue("projNm") as string;
+ return (
+ <div className="min-w-48 max-w-64">
+ <TooltipProvider>
+ <Tooltip>
+ <TooltipTrigger asChild>
+ <span className="truncate block text-sm">
+ {projNm || "N/A"}
+ </span>
+ </TooltipTrigger>
+ <TooltipContent>
+ <p className="max-w-xs">{projNm || "N/A"}</p>
+ </TooltipContent>
+ </Tooltip>
+ </TooltipProvider>
+ </div>
+ );
+ },
+ enableSorting: true,
+ enableHiding: true,
+ },
+ // {
+ // accessorKey: "quotationCode",
+ // header: ({ column }) => (
+ // <DataTableColumnHeaderSimple column={column} title="견적서 번호" />
+ // ),
+ // cell: ({ row }) => {
+ // const quotationCode = row.getValue("quotationCode") as string;
+ // return (
+ // <div className="min-w-32">
+ // <span className="font-mono text-sm">{quotationCode || "미부여"}</span>
+ // </div>
+ // );
+ // },
+ // enableSorting: true,
+ // enableHiding: true,
+ // },
+ // {
+ // accessorKey: "quotationVersion",
+ // header: ({ column }) => (
+ // <DataTableColumnHeaderSimple column={column} title="버전" />
+ // ),
+ // cell: ({ row }) => {
+ // const quotationVersion = row.getValue("quotationVersion") as number;
+ // return (
+ // <div className="w-16 text-center">
+ // <span className="text-sm">{quotationVersion || 1}</span>
+ // </div>
+ // );
+ // },
+ // enableSorting: true,
+ // enableHiding: true,
+ // },
+ {
+ id: "items",
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="아이템" />
+ ),
+ cell: ({ row }) => {
+ const quotation = row.original
+ const itemCount = quotation.itemCount || 0
+
+ const handleClick = () => {
+ const rfq = {
+ id: quotation.rfqId,
+ rfqCode: quotation.rfqCode,
+ status: quotation.rfqStatus,
+ rfqType: "SHIP" as const, // 기본값
+ }
+ openItemsDialog(rfq)
+ }
+
+ return (
+ <div className="w-20">
+ <TooltipProvider>
+ <Tooltip>
+ <TooltipTrigger asChild>
+ <Button
+ variant="ghost"
+ size="sm"
+ className="relative h-8 w-8 p-0 group"
+ onClick={handleClick}
+ aria-label={`View ${itemCount} items`}
+ >
+ <Package className="h-4 w-4 text-muted-foreground group-hover:text-primary transition-colors" />
+ {itemCount > 0 && (
+ <span className="pointer-events-none absolute -top-1 -right-1 inline-flex h-4 min-w-[1rem] items-center justify-center rounded-full bg-primary px-1 text-[0.625rem] font-medium leading-none text-primary-foreground">
+ {itemCount}
+ </span>
+ )}
+ <span className="sr-only">
+ {itemCount > 0 ? `${itemCount} 아이템` : "아이템 없음"}
+ </span>
+ </Button>
+ </TooltipTrigger>
+ <TooltipContent>
+ <p>{itemCount > 0 ? `${itemCount}개 아이템 보기` : "아이템 없음"}</p>
+ </TooltipContent>
+ </Tooltip>
+ </TooltipProvider>
+ </div>
+ )
+ },
+ enableSorting: false,
+ enableHiding: true,
+ },
+ {
+ id: "attachments",
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="첨부파일" />
+ ),
+ cell: ({ row }) => {
+ const quotation = row.original
+ const attachmentCount = quotation.attachmentCount || 0
+ const handleClick = () => {
+ openAttachmentsSheet(quotation.rfqId)
+ }
+
+ return (
+ <div className="w-20">
+ <TooltipProvider>
+ <Tooltip>
+ <TooltipTrigger asChild>
+ <Button
+ variant="ghost"
+ size="sm"
+ className="relative h-8 w-8 p-0 group"
+ onClick={handleClick}
+ aria-label={
+ attachmentCount > 0 ? `View ${attachmentCount} attachments` : "No attachments"
+ }
+ >
+ <Paperclip className="h-4 w-4 text-muted-foreground group-hover:text-primary transition-colors" />
+ {attachmentCount > 0 && (
+ <span className="pointer-events-none absolute -top-1 -right-1 inline-flex h-4 min-w-[1rem] items-center justify-center rounded-full bg-primary px-1 text-[0.625rem] font-medium leading-none text-primary-foreground">
+ {attachmentCount}
+ </span>
+ )}
+ <span className="sr-only">
+ {attachmentCount > 0 ? `${attachmentCount} 첨부파일` : "첨부파일 없음"}
+ </span>
+ </Button>
+ </TooltipTrigger>
+ <TooltipContent>
+ <p>{attachmentCount > 0 ? `${attachmentCount}개 첨부파일 보기` : "첨부파일 없음"}</p>
+ </TooltipContent>
+ </Tooltip>
+ </TooltipProvider>
+ </div>
+ )
+ },
+ enableSorting: false,
+ enableHiding: true,
+ },
+ {
+ accessorKey: "status",
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="상태" />
+ ),
+ cell: ({ row }) => {
+ const status = row.getValue("status") as string;
+
+ const statusConfig = TECH_SALES_QUOTATION_STATUS_CONFIG[status as keyof typeof TECH_SALES_QUOTATION_STATUS_CONFIG] || {
+ label: status,
+ variant: "secondary" as const
+ };
+
+ return (
+ <div className="w-24">
+ <Badge variant={statusConfig.variant} className="text-xs">
+ {statusConfig.label}
+ </Badge>
+ </div>
+ );
+ },
+ enableSorting: true,
+ enableHiding: false,
+ filterFn: (row, id, value) => {
+ return value.includes(row.getValue(id));
+ },
+ },
+ {
+ accessorKey: "currency",
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="통화" />
+ ),
+ cell: ({ row }) => {
+ const currency = row.getValue("currency") as string;
+ return (
+ <div className="w-16">
+ <span className="font-mono text-sm">{currency || "N/A"}</span>
+ </div>
+ );
+ },
+ enableSorting: true,
+ enableHiding: true,
+ },
+ {
+ accessorKey: "totalPrice",
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="총액" />
+ ),
+ cell: ({ row }) => {
+ const totalPrice = row.getValue("totalPrice") as string;
+ const currency = row.getValue("currency") as string;
+
+ if (!totalPrice || totalPrice === "0") {
+ return (
+ <div className="w-32 text-right">
+ <span className="text-muted-foreground text-sm">미입력</span>
+ </div>
+ );
+ }
+
+ return (
+ <div className="w-32 text-right">
+ <span className="font-mono text-sm">
+ {formatCurrency(parseFloat(totalPrice), currency || "USD")}
+ </span>
+ </div>
+ );
+ },
+ enableSorting: true,
+ enableHiding: true,
+ },
+ {
+ accessorKey: "validUntil",
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="유효기간" />
+ ),
+ cell: ({ row }) => {
+ const validUntil = row.getValue("validUntil") as Date;
+ return (
+ <div className="w-28">
+ <span className="text-sm">
+ {validUntil ? formatDate(validUntil) : "N/A"}
+ </span>
+ </div>
+ );
+ },
+ enableSorting: true,
+ enableHiding: true,
+ },
+ {
+ accessorKey: "submittedAt",
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="제출일" />
+ ),
+ cell: ({ row }) => {
+ const submittedAt = row.getValue("submittedAt") as Date;
+ return (
+ <div className="w-36">
+ <span className="text-sm">
+ {submittedAt ? formatDateTime(submittedAt) : "미제출"}
+ </span>
+ </div>
+ );
+ },
+ enableSorting: true,
+ enableHiding: true,
+ },
+ // {
+ // accessorKey: "acceptedAt",
+ // header: ({ column }) => (
+ // <DataTableColumnHeaderSimple column={column} title="승인일" />
+ // ),
+ // cell: ({ row }) => {
+ // const acceptedAt = row.getValue("acceptedAt") as Date;
+ // return (
+ // <div className="w-36">
+ // <span className="text-sm">
+ // {acceptedAt ? formatDateTime(acceptedAt) : "미승인"}
+ // </span>
+ // </div>
+ // );
+ // },
+ // enableSorting: true,
+ // enableHiding: true,
+ // },
+ {
+ accessorKey: "dueDate",
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="마감일" />
+ ),
+ cell: ({ row }) => {
+ const dueDate = row.getValue("dueDate") as Date;
+ const isOverdue = dueDate && new Date() > new Date(dueDate);
+
+ return (
+ <div className="w-28">
+ <span className={`text-sm ${isOverdue ? "text-red-600 font-medium" : ""}`}>
+ {dueDate ? formatDate(dueDate) : "N/A"}
+ </span>
+ </div>
+ );
+ },
+ enableSorting: true,
+ enableHiding: true,
+ },
+ // {
+ // accessorKey: "rejectionReason",
+ // header: ({ column }) => (
+ // <DataTableColumnHeaderSimple column={column} title="반려사유" />
+ // ),
+ // cell: ({ row }) => {
+ // const rejectionReason = row.getValue("rejectionReason") as string;
+ // return (
+ // <div className="min-w-48 max-w-64">
+ // {rejectionReason ? (
+ // <TooltipProvider>
+ // <Tooltip>
+ // <TooltipTrigger asChild>
+ // <span className="truncate block text-sm text-red-600">
+ // {rejectionReason}
+ // </span>
+ // </TooltipTrigger>
+ // <TooltipContent>
+ // <p className="max-w-xs">{rejectionReason}</p>
+ // </TooltipContent>
+ // </Tooltip>
+ // </TooltipProvider>
+ // ) : (
+ // <span className="text-sm text-muted-foreground">N/A</span>
+ // )}
+ // </div>
+ // );
+ // },
+ // enableSorting: false,
+ // enableHiding: true,
+ // },
+ {
+ accessorKey: "createdAt",
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="생성일" />
+ ),
+ cell: ({ row }) => {
+ const createdAt = row.getValue("createdAt") as Date;
+ return (
+ <div className="w-36">
+ <span className="text-sm">
+ {createdAt ? formatDateTime(createdAt) : "N/A"}
+ </span>
+ </div>
+ );
+ },
+ enableSorting: true,
+ enableHiding: true,
+ },
+ {
+ accessorKey: "updatedAt",
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="수정일" />
+ ),
+ cell: ({ row }) => {
+ const updatedAt = row.getValue("updatedAt") as Date;
+ return (
+ <div className="w-36">
+ <span className="text-sm">
+ {updatedAt ? formatDateTime(updatedAt) : "N/A"}
+ </span>
+ </div>
+ );
+ },
+ enableSorting: true,
+ enableHiding: true,
+ },
+ // {
+ // accessorKey: "createdByName",
+ // header: ({ column }) => (
+ // <DataTableColumnHeaderSimple column={column} title="생성자" />
+ // ),
+ // cell: ({ row }) => {
+ // const createdByName = row.getValue("createdByName") as string;
+ // return (
+ // <div className="w-24">
+ // <span className="text-sm">{createdByName || "N/A"}</span>
+ // </div>
+ // );
+ // },
+ // enableSorting: true,
+ // enableHiding: true,
+ // },
+ // {
+ // accessorKey: "updatedByName",
+ // header: ({ column }) => (
+ // <DataTableColumnHeaderSimple column={column} title="수정자" />
+ // ),
+ // cell: ({ row }) => {
+ // const updatedByName = row.getValue("updatedByName") as string;
+ // return (
+ // <div className="w-24">
+ // <span className="text-sm">{updatedByName || "N/A"}</span>
+ // </div>
+ // );
+ // },
+ // enableSorting: true,
+ // enableHiding: true,
+ // },
+ {
+ id: "actions",
+ header: "작업",
+ cell: ({ row }) => {
+ const quotation = row.original;
+ const rfqCode = quotation.rfqCode || "N/A";
+ const tooltipText = `${rfqCode} 견적서 작성`;
+ const isRejected = quotation.status === "Rejected";
+ const isAccepted = quotation.status === "Accepted";
+ const isDisabled = isRejected || isAccepted;
+
+ return (
+ <div className="w-16">
+ <TooltipProvider>
+ <Tooltip>
+ <TooltipTrigger asChild>
+ <Button
+ variant="ghost"
+ size="icon"
+ onClick={() => {
+ if (!isDisabled) {
+ router.push(`/ko/partners/techsales/rfq-ship/${quotation.id}`);
+ }
+ }}
+ className="h-8 w-8"
+ disabled={isDisabled}
+ >
+ <Edit className="h-4 w-4" />
+ <span className="sr-only">견적서 작성</span>
+ </Button>
+ </TooltipTrigger>
+ <TooltipContent>
+ <p>{isRejected ? "거절된 견적서는 편집할 수 없습니다" : isAccepted ? "승인된 견적서는 편집할 수 없습니다" : tooltipText}</p>
+ </TooltipContent>
+ </Tooltip>
+ </TooltipProvider>
+ </div>
+ );
+ },
+ enableSorting: false,
+ enableHiding: false,
+ },
+ {
+ id: "contacts",
+ header: "담당자",
+ cell: ({ row }) => {
+ const quotation = row.original;
+
+ const handleClick = () => {
+ openContactsDialog(quotation.id, quotation.vendorName);
+ };
+
+ return (
+ <div className="w-20">
+ <TooltipProvider>
+ <Tooltip>
+ <TooltipTrigger asChild>
+ <Button
+ variant="ghost"
+ size="sm"
+ className="h-8 w-8 p-0 group"
+ onClick={handleClick}
+ aria-label="담당자 정보 보기"
+ >
+ <Users className="h-4 w-4 text-muted-foreground group-hover:text-primary transition-colors" />
+ <span className="sr-only">담당자 정보 보기</span>
+ </Button>
+ </TooltipTrigger>
+ <TooltipContent>
+ <p>RFQ 발송 담당자 보기</p>
+ </TooltipContent>
+ </Tooltip>
+ </TooltipProvider>
+ </div>
+ );
+ },
+ enableSorting: false,
+ enableHiding: true,
+ },
+ ];
} \ No newline at end of file
diff --git a/lib/techsales-rfq/vendor-response/table/vendor-quotations-table.tsx b/lib/techsales-rfq/vendor-response/table/vendor-quotations-table.tsx
index e79d7c4d..5bb219bf 100644
--- a/lib/techsales-rfq/vendor-response/table/vendor-quotations-table.tsx
+++ b/lib/techsales-rfq/vendor-response/table/vendor-quotations-table.tsx
@@ -1,505 +1,525 @@
-// lib/techsales-rfq/vendor-response/table/vendor-quotations-table.tsx
-"use client"
-
-import * as React from "react"
-import { useSearchParams } from "next/navigation"
-import { type DataTableAdvancedFilterField, type DataTableFilterField } from "@/types/table"
-import { useDataTable } from "@/hooks/use-data-table"
-import { DataTable } from "@/components/data-table/data-table"
-import { DataTableAdvancedToolbar } from "@/components/data-table/data-table-advanced-toolbar"
-import { TechSalesVendorQuotations, TECH_SALES_QUOTATION_STATUSES, TECH_SALES_QUOTATION_STATUS_CONFIG } from "@/db/schema"
-import { useRouter } from "next/navigation"
-import { getColumns } from "./vendor-quotations-table-columns"
-import { TechSalesRfqAttachmentsSheet, ExistingTechSalesAttachment } from "../../table/tech-sales-rfq-attachments-sheet"
-import { RfqItemsViewDialog } from "../../table/rfq-items-view-dialog"
-import { getTechSalesRfqAttachments, getVendorQuotations, rejectTechSalesVendorQuotations } from "@/lib/techsales-rfq/service"
-import { toast } from "sonner"
-import { Skeleton } from "@/components/ui/skeleton"
-import { Button } from "@/components/ui/button"
-import { X } from "lucide-react"
-import {
- AlertDialog,
- AlertDialogAction,
- AlertDialogCancel,
- AlertDialogContent,
- AlertDialogDescription,
- AlertDialogFooter,
- AlertDialogHeader,
- AlertDialogTitle,
- AlertDialogTrigger,
-} from "@/components/ui/alert-dialog"
-import { Textarea } from "@/components/ui/textarea"
-import { Label } from "@/components/ui/label"
-
-interface QuotationWithRfqCode extends TechSalesVendorQuotations {
- rfqCode?: string | null;
- materialCode?: string | null;
- dueDate?: Date;
- rfqStatus?: string;
- itemName?: string | null;
- projNm?: string | null;
- description?: string | null;
- attachmentCount?: number;
- itemCount?: number;
- pspid?: string | null;
- sector?: string | null;
- vendorName?: string | null;
- vendorCode?: string | null;
- createdByName?: string | null;
- updatedByName?: string | null;
-}
-
-interface VendorQuotationsTableProps {
- vendorId: string;
- rfqType?: "SHIP" | "TOP" | "HULL";
-}
-
-// 로딩 스켈레톤 컴포넌트
-function TableLoadingSkeleton() {
- return (
- <div className="w-full space-y-3">
- {/* 툴바 스켈레톤 */}
- <div className="flex items-center justify-between">
- <div className="flex items-center space-x-2">
- <Skeleton className="h-10 w-[250px]" />
- <Skeleton className="h-10 w-[100px]" />
- </div>
- <div className="flex items-center space-x-2">
- <Skeleton className="h-10 w-[120px]" />
- <Skeleton className="h-10 w-[100px]" />
- </div>
- </div>
-
- {/* 테이블 헤더 스켈레톤 */}
- <div className="rounded-md border">
- <div className="border-b p-4">
- <div className="flex items-center space-x-4">
- <Skeleton className="h-4 w-[100px]" />
- <Skeleton className="h-4 w-[150px]" />
- <Skeleton className="h-4 w-[120px]" />
- <Skeleton className="h-4 w-[100px]" />
- <Skeleton className="h-4 w-[130px]" />
- <Skeleton className="h-4 w-[100px]" />
- <Skeleton className="h-4 w-[80px]" />
- </div>
- </div>
-
- {/* 테이블 행 스켈레톤 */}
- {Array.from({ length: 5 }).map((_, index) => (
- <div key={index} className="border-b p-4 last:border-b-0">
- <div className="flex items-center space-x-4">
- <Skeleton className="h-4 w-[100px]" />
- <Skeleton className="h-4 w-[150px]" />
- <Skeleton className="h-4 w-[120px]" />
- <Skeleton className="h-4 w-[100px]" />
- <Skeleton className="h-4 w-[130px]" />
- <Skeleton className="h-4 w-[100px]" />
- <Skeleton className="h-4 w-[80px]" />
- </div>
- </div>
- ))}
- </div>
-
- {/* 페이지네이션 스켈레톤 */}
- <div className="flex items-center justify-between">
- <Skeleton className="h-8 w-[200px]" />
- <div className="flex items-center space-x-2">
- <Skeleton className="h-8 w-[100px]" />
- <Skeleton className="h-8 w-[60px]" />
- <Skeleton className="h-8 w-[100px]" />
- </div>
- </div>
- </div>
- )
-}
-
-export function VendorQuotationsTable({ vendorId, rfqType }: VendorQuotationsTableProps) {
- const searchParams = useSearchParams()
- const router = useRouter()
-
- // 첨부파일 시트 상태
- const [attachmentsOpen, setAttachmentsOpen] = React.useState(false)
- const [selectedRfqForAttachments, setSelectedRfqForAttachments] = React.useState<{ id: number; rfqCode: string | null; status: string } | null>(null)
- const [attachmentsDefault, setAttachmentsDefault] = React.useState<ExistingTechSalesAttachment[]>([])
-
- // 아이템 다이얼로그 상태
- const [itemsDialogOpen, setItemsDialogOpen] = React.useState(false)
- const [selectedRfqForItems, setSelectedRfqForItems] = React.useState<{ id: number; rfqCode?: string; status?: string; rfqType?: "SHIP" | "TOP" | "HULL"; } | null>(null)
-
- // 거절 다이얼로그 상태
- const [rejectDialogOpen, setRejectDialogOpen] = React.useState(false)
- const [rejectionReason, setRejectionReason] = React.useState("")
- const [isRejecting, setIsRejecting] = React.useState(false)
-
- // 데이터 로딩 상태
- const [data, setData] = React.useState<QuotationWithRfqCode[]>([])
- const [pageCount, setPageCount] = React.useState(0)
- const [total, setTotal] = React.useState(0)
- const [isLoading, setIsLoading] = React.useState(true)
- const [isInitialLoad, setIsInitialLoad] = React.useState(true) // 최초 로딩 구분
-
- // URL 파라미터에서 설정 읽기
- const initialSettings = React.useMemo(() => ({
- page: parseInt(searchParams?.get('page') || '1'),
- perPage: parseInt(searchParams?.get('perPage') || '10'),
- sort: searchParams?.get('sort') ? JSON.parse(searchParams.get('sort')!) : [{ id: "updatedAt", desc: true }],
- filters: searchParams?.get('filters') ? JSON.parse(searchParams.get('filters')!) : [],
- joinOperator: (searchParams?.get('joinOperator') as "and" | "or") || "and",
- basicFilters: searchParams?.get('basicFilters') ? JSON.parse(searchParams.get('basicFilters')!) : [],
- basicJoinOperator: (searchParams?.get('basicJoinOperator') as "and" | "or") || "and",
- search: searchParams?.get('search') || '',
- from: searchParams?.get('from') || '',
- to: searchParams?.get('to') || '',
- }), [searchParams])
-
- // 데이터 로드 함수
- const loadData = React.useCallback(async () => {
- try {
- setIsLoading(true)
-
- console.log('🔍 [VendorQuotationsTable] 데이터 로드 요청:', {
- vendorId,
- settings: initialSettings
- })
-
- const result = await getVendorQuotations({
- page: initialSettings.page,
- perPage: initialSettings.perPage,
- sort: initialSettings.sort,
- filters: initialSettings.filters,
- joinOperator: initialSettings.joinOperator,
- basicFilters: initialSettings.basicFilters,
- basicJoinOperator: initialSettings.basicJoinOperator,
- search: initialSettings.search,
- from: initialSettings.from,
- to: initialSettings.to,
- rfqType: rfqType,
- }, vendorId)
-
- console.log('🔍 [VendorQuotationsTable] 데이터 로드 결과:', {
- dataLength: result.data.length,
- pageCount: result.pageCount,
- total: result.total
- })
-
- setData(result.data as QuotationWithRfqCode[])
- setPageCount(result.pageCount)
- setTotal(result.total)
- } catch (error) {
- console.error('데이터 로드 오류:', error)
- toast.error('데이터를 불러오는 중 오류가 발생했습니다.')
- } finally {
- setIsLoading(false)
- setIsInitialLoad(false)
- }
- }, [vendorId, initialSettings, rfqType])
-
- // URL 파라미터 변경 감지 및 데이터 재로드 (초기 로드 포함)
- React.useEffect(() => {
- loadData()
- }, [
- searchParams?.get('page'),
- searchParams?.get('perPage'),
- searchParams?.get('sort'),
- searchParams?.get('filters'),
- searchParams?.get('joinOperator'),
- searchParams?.get('basicFilters'),
- searchParams?.get('basicJoinOperator'),
- searchParams?.get('search'),
- searchParams?.get('from'),
- searchParams?.get('to'),
- // vendorId와 rfqType 변경도 감지
- vendorId,
- rfqType
- ])
-
- // 데이터 안정성을 위한 메모이제이션
- const stableData = React.useMemo(() => {
- return data;
- }, [data.length, data.map(item => `${item.id}-${item.status}-${item.updatedAt}`).join(',')]);
-
- // 첨부파일 시트 열기 함수 (벤더는 RFQ_COMMON 타입만 조회)
- const openAttachmentsSheet = React.useCallback(async (rfqId: number) => {
- try {
- // RFQ 정보 조회 (data에서 rfqId에 해당하는 데이터 찾기)
- const quotationWithRfq = data.find(item => item.rfqId === rfqId)
- if (!quotationWithRfq) {
- toast.error("RFQ 정보를 찾을 수 없습니다.")
- return
- }
-
- // 실제 첨부파일 목록 조회 API 호출
- const result = await getTechSalesRfqAttachments(rfqId)
-
- if (result.error) {
- toast.error(result.error)
- return
- }
-
- // API 응답을 ExistingTechSalesAttachment 형식으로 변환하고 RFQ_COMMON 타입만 필터링
- const attachments: ExistingTechSalesAttachment[] = result.data
- .filter(att => att.attachmentType === "RFQ_COMMON") // 벤더는 RFQ_COMMON 타입만 조회
- .map(att => ({
- id: att.id,
- techSalesRfqId: att.techSalesRfqId || rfqId,
- fileName: att.fileName,
- originalFileName: att.originalFileName,
- filePath: att.filePath,
- fileSize: att.fileSize || undefined,
- fileType: att.fileType || undefined,
- attachmentType: att.attachmentType as "RFQ_COMMON" | "TBE_RESULT" | "CBE_RESULT",
- description: att.description || undefined,
- createdBy: att.createdBy,
- createdAt: att.createdAt,
- }))
-
- setAttachmentsDefault(attachments)
- setSelectedRfqForAttachments({
- id: rfqId,
- rfqCode: quotationWithRfq.rfqCode || null,
- status: quotationWithRfq.rfqStatus || "Unknown"
- })
- setAttachmentsOpen(true)
- } catch (error) {
- console.error("첨부파일 조회 오류:", error)
- toast.error("첨부파일 조회 중 오류가 발생했습니다.")
- }
- }, [data])
-
- // 아이템 다이얼로그 열기 함수
- const openItemsDialog = React.useCallback((rfq: { id: number; rfqCode?: string; status?: string; rfqType?: "SHIP" | "TOP" | "HULL"; }) => {
- setSelectedRfqForItems(rfq)
- setItemsDialogOpen(true)
- }, [])
-
- // 거절 처리 함수
- const handleRejectQuotations = React.useCallback(async () => {
- if (!table) return;
-
- const selectedRows = table.getFilteredSelectedRowModel().rows;
- const quotationIds = selectedRows.map(row => row.original.id);
-
- if (quotationIds.length === 0) {
- toast.error("거절할 견적서를 선택해주세요.");
- return;
- }
-
- // 거절할 수 없는 상태의 견적서가 있는지 확인
- const invalidStatuses = selectedRows.filter(row =>
- row.original.status === "Accepted" || row.original.status === "Rejected"
- );
-
- if (invalidStatuses.length > 0) {
- toast.error("이미 승인되었거나 거절된 견적서는 거절할 수 없습니다.");
- return;
- }
-
- setIsRejecting(true);
-
- try {
- const result = await rejectTechSalesVendorQuotations({
- quotationIds,
- rejectionReason: rejectionReason.trim() || undefined,
- });
-
- if (result.success) {
- toast.success(result.message);
- setRejectDialogOpen(false);
- setRejectionReason("");
- table.resetRowSelection();
- // 데이터 다시 로드
- await loadData();
- } else {
- toast.error(result.error || "견적서 거절 중 오류가 발생했습니다.");
- }
- } catch (error) {
- console.error("견적서 거절 오류:", error);
- toast.error("견적서 거절 중 오류가 발생했습니다.");
- } finally {
- setIsRejecting(false);
- }
- }, [rejectionReason, loadData]);
-
- // 테이블 컬럼 정의
- const columns = React.useMemo(() => getColumns({
- router,
- openAttachmentsSheet,
- openItemsDialog,
- }), [router, openAttachmentsSheet, openItemsDialog])
-
- // 필터 필드
- const filterFields = React.useMemo<DataTableFilterField<QuotationWithRfqCode>[]>(() => [
- {
- id: "status",
- label: "상태",
- options: Object.entries(TECH_SALES_QUOTATION_STATUSES).map(([, statusValue]) => ({
- label: TECH_SALES_QUOTATION_STATUS_CONFIG[statusValue].label,
- value: statusValue,
- }))
- },
- {
- id: "rfqCode",
- label: "RFQ 번호",
- placeholder: "RFQ 번호 검색...",
- },
- {
- id: "materialCode",
- label: "자재 그룹",
- placeholder: "자재 그룹 검색...",
- }
- ], [])
-
- // 고급 필터 필드
- const advancedFilterFields = React.useMemo<DataTableAdvancedFilterField<QuotationWithRfqCode>[]>(() => [
- {
- id: "rfqCode",
- label: "RFQ 번호",
- type: "text",
- },
- {
- id: "materialCode",
- label: "자재 그룹",
- type: "text",
- },
- {
- id: "status",
- label: "상태",
- type: "multi-select",
- options: Object.entries(TECH_SALES_QUOTATION_STATUSES).map(([, statusValue]) => ({
- label: TECH_SALES_QUOTATION_STATUS_CONFIG[statusValue].label,
- value: statusValue,
- })),
- },
- {
- id: "validUntil",
- label: "유효기간",
- type: "date",
- },
- {
- id: "submittedAt",
- label: "제출일",
- type: "date",
- },
- ], [])
-
- // useDataTable 훅 사용
- const { table } = useDataTable({
- data: stableData,
- columns: columns as any, // 타입 오류 임시 해결
- pageCount,
- rowCount: total,
- filterFields,
- enablePinning: true,
- enableAdvancedFilter: true,
- enableColumnResizing: true,
- columnResizeMode: 'onChange',
- enableRowSelection: true, // 행 선택 활성화
- initialState: {
- sorting: initialSettings.sort,
- columnPinning: { right: ["actions", "items", "attachments"] },
- },
- getRowId: (originalRow) => String(originalRow.id),
- shallow: false,
- clearOnDefault: true,
- defaultColumn: {
- minSize: 50,
- maxSize: 500,
- },
- })
-
- // 최초 로딩 시 전체 스켈레톤 표시
- if (isInitialLoad && isLoading) {
- return (
- <div className="w-full">
- <div className="overflow-x-auto">
- <TableLoadingSkeleton />
- </div>
- </div>
- )
- }
-
- return (
- <div className="w-full">
- <div className="overflow-x-auto">
- <div className="relative">
- <DataTable
- table={table}
- className="min-w-full"
- >
- <DataTableAdvancedToolbar
- table={table}
- filterFields={advancedFilterFields}
- shallow={false}
- >
- {/* 선택된 행이 있을 때 거절 버튼 표시 */}
- {table && table.getFilteredSelectedRowModel().rows.length > 0 && (
- <AlertDialog open={rejectDialogOpen} onOpenChange={setRejectDialogOpen}>
- <AlertDialogTrigger asChild>
- <Button variant="destructive" size="sm">
- <X className="mr-2 h-4 w-4" />
- 선택한 견적서 거절 ({table.getFilteredSelectedRowModel().rows.length}개)
- </Button>
- </AlertDialogTrigger>
- <AlertDialogContent>
- <AlertDialogHeader>
- <AlertDialogTitle>견적서 거절</AlertDialogTitle>
- <AlertDialogDescription>
- 선택한 {table.getFilteredSelectedRowModel().rows.length}개의 견적서를 거절하시겠습니까?
- 거절된 견적서는 다시 되돌릴 수 없습니다.
- </AlertDialogDescription>
- </AlertDialogHeader>
- <div className="grid gap-4 py-4">
- <div className="grid gap-2">
- <Label htmlFor="rejection-reason">거절 사유 (선택사항)</Label>
- <Textarea
- id="rejection-reason"
- placeholder="거절 사유를 입력하세요..."
- value={rejectionReason}
- onChange={(e) => setRejectionReason(e.target.value)}
- />
- </div>
- </div>
- <AlertDialogFooter>
- <AlertDialogCancel>취소</AlertDialogCancel>
- <AlertDialogAction
- onClick={handleRejectQuotations}
- disabled={isRejecting}
- className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
- >
- {isRejecting ? "처리 중..." : "거절"}
- </AlertDialogAction>
- </AlertDialogFooter>
- </AlertDialogContent>
- </AlertDialog>
- )}
-
- {!isInitialLoad && isLoading && (
- <div className="flex items-center gap-2 text-sm text-muted-foreground">
- <div className="animate-spin h-4 w-4 border-2 border-current border-t-transparent rounded-full" />
- 데이터 업데이트 중...
- </div>
- )}
- </DataTableAdvancedToolbar>
- </DataTable>
- </div>
- </div>
-
- {/* 첨부파일 관리 시트 (읽기 전용) */}
- <TechSalesRfqAttachmentsSheet
- open={attachmentsOpen}
- onOpenChange={setAttachmentsOpen}
- defaultAttachments={attachmentsDefault}
- rfq={selectedRfqForAttachments}
- attachmentType="RFQ_COMMON" // 벤더는 RFQ_COMMON 타입만 조회
- readOnly={true} // 벤더는 항상 읽기 전용
- />
-
- {/* 아이템 보기 다이얼로그 */}
- <RfqItemsViewDialog
- open={itemsDialogOpen}
- onOpenChange={setItemsDialogOpen}
- rfq={selectedRfqForItems}
- />
- </div>
- );
+// lib/techsales-rfq/vendor-response/table/vendor-quotations-table.tsx
+"use client"
+
+import * as React from "react"
+import { useSearchParams } from "next/navigation"
+import { type DataTableAdvancedFilterField, type DataTableFilterField } from "@/types/table"
+import { useDataTable } from "@/hooks/use-data-table"
+import { DataTable } from "@/components/data-table/data-table"
+import { DataTableAdvancedToolbar } from "@/components/data-table/data-table-advanced-toolbar"
+import { TechSalesVendorQuotations, TECH_SALES_QUOTATION_STATUSES, TECH_SALES_QUOTATION_STATUS_CONFIG } from "@/db/schema"
+import { useRouter } from "next/navigation"
+import { getColumns } from "./vendor-quotations-table-columns"
+import { TechSalesRfqAttachmentsSheet, ExistingTechSalesAttachment } from "../../table/tech-sales-rfq-attachments-sheet"
+import { RfqItemsViewDialog } from "../../table/rfq-items-view-dialog"
+import { QuotationContactsViewDialog } from "../../table/detail-table/quotation-contacts-view-dialog"
+import { getTechSalesRfqAttachments, getVendorQuotations, rejectTechSalesVendorQuotations } from "@/lib/techsales-rfq/service"
+import { toast } from "sonner"
+import { Skeleton } from "@/components/ui/skeleton"
+import { Button } from "@/components/ui/button"
+import { X } from "lucide-react"
+import {
+ AlertDialog,
+ AlertDialogAction,
+ AlertDialogCancel,
+ AlertDialogContent,
+ AlertDialogDescription,
+ AlertDialogFooter,
+ AlertDialogHeader,
+ AlertDialogTitle,
+ AlertDialogTrigger,
+} from "@/components/ui/alert-dialog"
+import { Textarea } from "@/components/ui/textarea"
+import { Label } from "@/components/ui/label"
+
+interface QuotationWithRfqCode extends TechSalesVendorQuotations {
+ rfqCode?: string | null;
+ materialCode?: string | null;
+ dueDate?: Date;
+ rfqStatus?: string;
+ itemName?: string | null;
+ projNm?: string | null;
+ description?: string | null;
+ attachmentCount?: number;
+ itemCount?: number;
+ pspid?: string | null;
+ sector?: string | null;
+ vendorName?: string | null;
+ vendorCode?: string | null;
+ createdByName?: string | null;
+ updatedByName?: string | null;
+}
+
+interface VendorQuotationsTableProps {
+ vendorId: string;
+ rfqType?: "SHIP" | "TOP" | "HULL";
+}
+
+// 로딩 스켈레톤 컴포넌트
+function TableLoadingSkeleton() {
+ return (
+ <div className="w-full space-y-3">
+ {/* 툴바 스켈레톤 */}
+ <div className="flex items-center justify-between">
+ <div className="flex items-center space-x-2">
+ <Skeleton className="h-10 w-[250px]" />
+ <Skeleton className="h-10 w-[100px]" />
+ </div>
+ <div className="flex items-center space-x-2">
+ <Skeleton className="h-10 w-[120px]" />
+ <Skeleton className="h-10 w-[100px]" />
+ </div>
+ </div>
+
+ {/* 테이블 헤더 스켈레톤 */}
+ <div className="rounded-md border">
+ <div className="border-b p-4">
+ <div className="flex items-center space-x-4">
+ <Skeleton className="h-4 w-[100px]" />
+ <Skeleton className="h-4 w-[150px]" />
+ <Skeleton className="h-4 w-[120px]" />
+ <Skeleton className="h-4 w-[100px]" />
+ <Skeleton className="h-4 w-[130px]" />
+ <Skeleton className="h-4 w-[100px]" />
+ <Skeleton className="h-4 w-[80px]" />
+ </div>
+ </div>
+
+ {/* 테이블 행 스켈레톤 */}
+ {Array.from({ length: 5 }).map((_, index) => (
+ <div key={index} className="border-b p-4 last:border-b-0">
+ <div className="flex items-center space-x-4">
+ <Skeleton className="h-4 w-[100px]" />
+ <Skeleton className="h-4 w-[150px]" />
+ <Skeleton className="h-4 w-[120px]" />
+ <Skeleton className="h-4 w-[100px]" />
+ <Skeleton className="h-4 w-[130px]" />
+ <Skeleton className="h-4 w-[100px]" />
+ <Skeleton className="h-4 w-[80px]" />
+ </div>
+ </div>
+ ))}
+ </div>
+
+ {/* 페이지네이션 스켈레톤 */}
+ <div className="flex items-center justify-between">
+ <Skeleton className="h-8 w-[200px]" />
+ <div className="flex items-center space-x-2">
+ <Skeleton className="h-8 w-[100px]" />
+ <Skeleton className="h-8 w-[60px]" />
+ <Skeleton className="h-8 w-[100px]" />
+ </div>
+ </div>
+ </div>
+ )
+}
+
+export function VendorQuotationsTable({ vendorId, rfqType }: VendorQuotationsTableProps) {
+ const searchParams = useSearchParams()
+ const router = useRouter()
+
+ // 첨부파일 시트 상태
+ const [attachmentsOpen, setAttachmentsOpen] = React.useState(false)
+ const [selectedRfqForAttachments, setSelectedRfqForAttachments] = React.useState<{ id: number; rfqCode: string | null; status: string } | null>(null)
+ const [attachmentsDefault, setAttachmentsDefault] = React.useState<ExistingTechSalesAttachment[]>([])
+
+ // 아이템 다이얼로그 상태
+ const [itemsDialogOpen, setItemsDialogOpen] = React.useState(false)
+ const [selectedRfqForItems, setSelectedRfqForItems] = React.useState<{ id: number; rfqCode?: string; status?: string; rfqType?: "SHIP" | "TOP" | "HULL"; } | null>(null)
+
+ // 담당자 조회 다이얼로그 상태
+ const [contactsDialogOpen, setContactsDialogOpen] = React.useState(false)
+ const [selectedQuotationForContacts, setSelectedQuotationForContacts] = React.useState<{ id: number; vendorName?: string } | null>(null)
+
+ // 거절 다이얼로그 상태
+ const [rejectDialogOpen, setRejectDialogOpen] = React.useState(false)
+ const [rejectionReason, setRejectionReason] = React.useState("")
+ const [isRejecting, setIsRejecting] = React.useState(false)
+
+ // 데이터 로딩 상태
+ const [data, setData] = React.useState<QuotationWithRfqCode[]>([])
+ const [pageCount, setPageCount] = React.useState(0)
+ const [total, setTotal] = React.useState(0)
+ const [isLoading, setIsLoading] = React.useState(true)
+ const [isInitialLoad, setIsInitialLoad] = React.useState(true) // 최초 로딩 구분
+
+ // URL 파라미터에서 설정 읽기
+ const initialSettings = React.useMemo(() => ({
+ page: parseInt(searchParams?.get('page') || '1'),
+ perPage: parseInt(searchParams?.get('perPage') || '10'),
+ sort: searchParams?.get('sort') ? JSON.parse(searchParams.get('sort')!) : [{ id: "updatedAt", desc: true }],
+ filters: searchParams?.get('filters') ? JSON.parse(searchParams.get('filters')!) : [],
+ joinOperator: (searchParams?.get('joinOperator') as "and" | "or") || "and",
+ basicFilters: searchParams?.get('basicFilters') ? JSON.parse(searchParams.get('basicFilters')!) : [],
+ basicJoinOperator: (searchParams?.get('basicJoinOperator') as "and" | "or") || "and",
+ search: searchParams?.get('search') || '',
+ from: searchParams?.get('from') || '',
+ to: searchParams?.get('to') || '',
+ }), [searchParams])
+
+ // 데이터 로드 함수
+ const loadData = React.useCallback(async () => {
+ try {
+ setIsLoading(true)
+
+ console.log('🔍 [VendorQuotationsTable] 데이터 로드 요청:', {
+ vendorId,
+ settings: initialSettings
+ })
+
+ const result = await getVendorQuotations({
+ page: initialSettings.page,
+ perPage: initialSettings.perPage,
+ sort: initialSettings.sort,
+ filters: initialSettings.filters,
+ joinOperator: initialSettings.joinOperator,
+ basicFilters: initialSettings.basicFilters,
+ basicJoinOperator: initialSettings.basicJoinOperator,
+ search: initialSettings.search,
+ from: initialSettings.from,
+ to: initialSettings.to,
+ rfqType: rfqType,
+ }, vendorId)
+
+ console.log('🔍 [VendorQuotationsTable] 데이터 로드 결과:', {
+ dataLength: result.data.length,
+ pageCount: result.pageCount,
+ total: result.total
+ })
+
+ setData(result.data as QuotationWithRfqCode[])
+ setPageCount(result.pageCount)
+ setTotal(result.total)
+ } catch (error) {
+ console.error('데이터 로드 오류:', error)
+ toast.error('데이터를 불러오는 중 오류가 발생했습니다.')
+ } finally {
+ setIsLoading(false)
+ setIsInitialLoad(false)
+ }
+ }, [vendorId, initialSettings, rfqType])
+
+ // URL 파라미터 변경 감지 및 데이터 재로드 (초기 로드 포함)
+ React.useEffect(() => {
+ loadData()
+ }, [
+ searchParams?.get('page'),
+ searchParams?.get('perPage'),
+ searchParams?.get('sort'),
+ searchParams?.get('filters'),
+ searchParams?.get('joinOperator'),
+ searchParams?.get('basicFilters'),
+ searchParams?.get('basicJoinOperator'),
+ searchParams?.get('search'),
+ searchParams?.get('from'),
+ searchParams?.get('to'),
+ // vendorId와 rfqType 변경도 감지
+ vendorId,
+ rfqType
+ ])
+
+ // 데이터 안정성을 위한 메모이제이션
+ const stableData = React.useMemo(() => {
+ return data;
+ }, [data.length, data.map(item => `${item.id}-${item.status}-${item.updatedAt}`).join(',')]);
+
+ // 첨부파일 시트 열기 함수 (벤더는 RFQ_COMMON 타입만 조회)
+ const openAttachmentsSheet = React.useCallback(async (rfqId: number) => {
+ try {
+ // RFQ 정보 조회 (data에서 rfqId에 해당하는 데이터 찾기)
+ const quotationWithRfq = data.find(item => item.rfqId === rfqId)
+ if (!quotationWithRfq) {
+ toast.error("RFQ 정보를 찾을 수 없습니다.")
+ return
+ }
+
+ // 실제 첨부파일 목록 조회 API 호출
+ const result = await getTechSalesRfqAttachments(rfqId)
+
+ if (result.error) {
+ toast.error(result.error)
+ return
+ }
+
+ // API 응답을 ExistingTechSalesAttachment 형식으로 변환하고 RFQ_COMMON 타입만 필터링
+ const attachments: ExistingTechSalesAttachment[] = result.data
+ .filter(att => att.attachmentType === "RFQ_COMMON") // 벤더는 RFQ_COMMON 타입만 조회
+ .map(att => ({
+ id: att.id,
+ techSalesRfqId: att.techSalesRfqId || rfqId,
+ fileName: att.fileName,
+ originalFileName: att.originalFileName,
+ filePath: att.filePath,
+ fileSize: att.fileSize || undefined,
+ fileType: att.fileType || undefined,
+ attachmentType: att.attachmentType as "RFQ_COMMON" | "TBE_RESULT" | "CBE_RESULT",
+ description: att.description || undefined,
+ createdBy: att.createdBy,
+ createdAt: att.createdAt,
+ }))
+
+ setAttachmentsDefault(attachments)
+ setSelectedRfqForAttachments({
+ id: rfqId,
+ rfqCode: quotationWithRfq.rfqCode || null,
+ status: quotationWithRfq.rfqStatus || "Unknown"
+ })
+ setAttachmentsOpen(true)
+ } catch (error) {
+ console.error("첨부파일 조회 오류:", error)
+ toast.error("첨부파일 조회 중 오류가 발생했습니다.")
+ }
+ }, [data])
+
+ // 아이템 다이얼로그 열기 함수
+ const openItemsDialog = React.useCallback((rfq: { id: number; rfqCode?: string; status?: string; rfqType?: "SHIP" | "TOP" | "HULL"; }) => {
+ setSelectedRfqForItems(rfq)
+ setItemsDialogOpen(true)
+ }, [])
+
+ // 담당자 조회 다이얼로그 열기 함수
+ const openContactsDialog = React.useCallback((quotationId: number, vendorName?: string) => {
+ setSelectedQuotationForContacts({ id: quotationId, vendorName })
+ setContactsDialogOpen(true)
+ }, [])
+
+ // 거절 처리 함수
+ const handleRejectQuotations = React.useCallback(async () => {
+ if (!table) return;
+
+ const selectedRows = table.getFilteredSelectedRowModel().rows;
+ const quotationIds = selectedRows.map(row => row.original.id);
+
+ if (quotationIds.length === 0) {
+ toast.error("거절할 견적서를 선택해주세요.");
+ return;
+ }
+
+ // 거절할 수 없는 상태의 견적서가 있는지 확인
+ const invalidStatuses = selectedRows.filter(row =>
+ row.original.status === "Accepted" || row.original.status === "Rejected"
+ );
+
+ if (invalidStatuses.length > 0) {
+ toast.error("이미 승인되었거나 거절된 견적서는 거절할 수 없습니다.");
+ return;
+ }
+
+ setIsRejecting(true);
+
+ try {
+ const result = await rejectTechSalesVendorQuotations({
+ quotationIds,
+ rejectionReason: rejectionReason.trim() || undefined,
+ });
+
+ if (result.success) {
+ toast.success(result.message);
+ setRejectDialogOpen(false);
+ setRejectionReason("");
+ table.resetRowSelection();
+ // 데이터 다시 로드
+ await loadData();
+ } else {
+ toast.error(result.error || "견적서 거절 중 오류가 발생했습니다.");
+ }
+ } catch (error) {
+ console.error("견적서 거절 오류:", error);
+ toast.error("견적서 거절 중 오류가 발생했습니다.");
+ } finally {
+ setIsRejecting(false);
+ }
+ }, [rejectionReason, loadData]);
+
+ // 테이블 컬럼 정의
+ const columns = React.useMemo(() => getColumns({
+ router,
+ openAttachmentsSheet,
+ openItemsDialog,
+ openContactsDialog,
+ }), [router, openAttachmentsSheet, openItemsDialog, openContactsDialog])
+
+ // 필터 필드
+ const filterFields = React.useMemo<DataTableFilterField<QuotationWithRfqCode>[]>(() => [
+ {
+ id: "status",
+ label: "상태",
+ options: Object.entries(TECH_SALES_QUOTATION_STATUSES).map(([, statusValue]) => ({
+ label: TECH_SALES_QUOTATION_STATUS_CONFIG[statusValue].label,
+ value: statusValue,
+ }))
+ },
+ {
+ id: "rfqCode",
+ label: "RFQ 번호",
+ placeholder: "RFQ 번호 검색...",
+ },
+ {
+ id: "materialCode",
+ label: "자재 그룹",
+ placeholder: "자재 그룹 검색...",
+ }
+ ], [])
+
+ // 고급 필터 필드
+ const advancedFilterFields = React.useMemo<DataTableAdvancedFilterField<QuotationWithRfqCode>[]>(() => [
+ {
+ id: "rfqCode",
+ label: "RFQ 번호",
+ type: "text",
+ },
+ {
+ id: "materialCode",
+ label: "자재 그룹",
+ type: "text",
+ },
+ {
+ id: "status",
+ label: "상태",
+ type: "multi-select",
+ options: Object.entries(TECH_SALES_QUOTATION_STATUSES).map(([, statusValue]) => ({
+ label: TECH_SALES_QUOTATION_STATUS_CONFIG[statusValue].label,
+ value: statusValue,
+ })),
+ },
+ {
+ id: "validUntil",
+ label: "유효기간",
+ type: "date",
+ },
+ {
+ id: "submittedAt",
+ label: "제출일",
+ type: "date",
+ },
+ ], [])
+
+ // useDataTable 훅 사용
+ const { table } = useDataTable({
+ data: stableData,
+ columns: columns as any, // 타입 오류 임시 해결
+ pageCount,
+ rowCount: total,
+ filterFields,
+ enablePinning: true,
+ enableAdvancedFilter: true,
+ enableColumnResizing: true,
+ columnResizeMode: 'onChange',
+ enableRowSelection: true, // 행 선택 활성화
+ initialState: {
+ sorting: initialSettings.sort,
+ columnPinning: { right: ["actions", "items", "attachments"] },
+ },
+ getRowId: (originalRow) => String(originalRow.id),
+ shallow: false,
+ clearOnDefault: true,
+ defaultColumn: {
+ minSize: 50,
+ maxSize: 500,
+ },
+ })
+
+ // 최초 로딩 시 전체 스켈레톤 표시
+ if (isInitialLoad && isLoading) {
+ return (
+ <div className="w-full">
+ <div className="overflow-x-auto">
+ <TableLoadingSkeleton />
+ </div>
+ </div>
+ )
+ }
+
+ return (
+ <div className="w-full">
+ <div className="overflow-x-auto">
+ <div className="relative">
+ <DataTable
+ table={table}
+ className="min-w-full"
+ >
+ <DataTableAdvancedToolbar
+ table={table}
+ filterFields={advancedFilterFields}
+ shallow={false}
+ >
+ {/* 선택된 행이 있을 때 거절 버튼 표시 */}
+ {table && table.getFilteredSelectedRowModel().rows.length > 0 && (
+ <AlertDialog open={rejectDialogOpen} onOpenChange={setRejectDialogOpen}>
+ <AlertDialogTrigger asChild>
+ <Button variant="destructive" size="sm">
+ <X className="mr-2 h-4 w-4" />
+ 선택한 견적서 거절 ({table.getFilteredSelectedRowModel().rows.length}개)
+ </Button>
+ </AlertDialogTrigger>
+ <AlertDialogContent>
+ <AlertDialogHeader>
+ <AlertDialogTitle>견적서 거절</AlertDialogTitle>
+ <AlertDialogDescription>
+ 선택한 {table.getFilteredSelectedRowModel().rows.length}개의 견적서를 거절하시겠습니까?
+ 거절된 견적서는 다시 되돌릴 수 없습니다.
+ </AlertDialogDescription>
+ </AlertDialogHeader>
+ <div className="grid gap-4 py-4">
+ <div className="grid gap-2">
+ <Label htmlFor="rejection-reason">거절 사유 (선택사항)</Label>
+ <Textarea
+ id="rejection-reason"
+ placeholder="거절 사유를 입력하세요..."
+ value={rejectionReason}
+ onChange={(e) => setRejectionReason(e.target.value)}
+ />
+ </div>
+ </div>
+ <AlertDialogFooter>
+ <AlertDialogCancel>취소</AlertDialogCancel>
+ <AlertDialogAction
+ onClick={handleRejectQuotations}
+ disabled={isRejecting}
+ className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
+ >
+ {isRejecting ? "처리 중..." : "거절"}
+ </AlertDialogAction>
+ </AlertDialogFooter>
+ </AlertDialogContent>
+ </AlertDialog>
+ )}
+
+ {!isInitialLoad && isLoading && (
+ <div className="flex items-center gap-2 text-sm text-muted-foreground">
+ <div className="animate-spin h-4 w-4 border-2 border-current border-t-transparent rounded-full" />
+ 데이터 업데이트 중...
+ </div>
+ )}
+ </DataTableAdvancedToolbar>
+ </DataTable>
+ </div>
+ </div>
+
+ {/* 첨부파일 관리 시트 (읽기 전용) */}
+ <TechSalesRfqAttachmentsSheet
+ open={attachmentsOpen}
+ onOpenChange={setAttachmentsOpen}
+ defaultAttachments={attachmentsDefault}
+ rfq={selectedRfqForAttachments}
+ attachmentType="RFQ_COMMON" // 벤더는 RFQ_COMMON 타입만 조회
+ readOnly={true} // 벤더는 항상 읽기 전용
+ />
+
+ {/* 아이템 보기 다이얼로그 */}
+ <RfqItemsViewDialog
+ open={itemsDialogOpen}
+ onOpenChange={setItemsDialogOpen}
+ rfq={selectedRfqForItems}
+ />
+
+ {/* 담당자 조회 다이얼로그 */}
+ <QuotationContactsViewDialog
+ open={contactsDialogOpen}
+ onOpenChange={setContactsDialogOpen}
+ quotationId={selectedQuotationForContacts?.id || null}
+ vendorName={selectedQuotationForContacts?.vendorName}
+ />
+ </div>
+ );
} \ No newline at end of file