diff options
Diffstat (limited to 'lib/techsales-rfq/vendor-response')
4 files changed, 393 insertions, 191 deletions
diff --git a/lib/techsales-rfq/vendor-response/buyer-communication-drawer.tsx b/lib/techsales-rfq/vendor-response/buyer-communication-drawer.tsx index 69ba0363..c8a0efc2 100644 --- a/lib/techsales-rfq/vendor-response/buyer-communication-drawer.tsx +++ b/lib/techsales-rfq/vendor-response/buyer-communication-drawer.tsx @@ -38,31 +38,30 @@ import { } from "@/components/ui/dialog" import { formatDateTime, formatFileSize } from "@/lib/utils" import { useSession } from "next-auth/react" -import { fetchBuyerVendorComments } from "../services" // 타입 정의 -interface Comment { - id: number; - rfqId: number; - vendorId: number | null // null 허용으로 변경 - userId?: number | null // null 허용으로 변경 - content: string; - isVendorComment: boolean | null; // null 허용으로 변경 - createdAt: Date; - updatedAt: Date; - userName?: string | null // null 허용으로 변경 - vendorName?: string | null // null 허용으로 변경 - attachments: Attachment[]; - isRead: boolean | null // null 허용으로 변경 +export interface TechSalesAttachment { + id: number + fileName: string + fileSize: number + fileType: string | null + filePath: string + uploadedAt: Date } -interface Attachment { - id: number; - fileName: string; - fileSize: number; - fileType: string | null; // 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 } // 프롭스 정의 @@ -73,15 +72,61 @@ interface BuyerCommunicationDrawerProps { id: number; rfqId: number; vendorId: number; - quotationCode: string; + quotationCode: string | null; rfq?: { - rfqCode: string; + 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: { @@ -89,7 +134,7 @@ export function sendVendorCommentClient(params: { vendorId: number; content: string; attachments?: File[]; -}): Promise<Comment> { +}): Promise<TechSalesComment> { // 폼 데이터 생성 (파일 첨부를 위해) const formData = new FormData(); formData.append('rfqId', params.rfqId.toString()); @@ -104,8 +149,10 @@ export function sendVendorCommentClient(params: { }); } - // API 엔드포인트 구성 (벤더 API 경로) - const url = `/api/procurement-rfqs/${params.rfqId}/vendors/${params.vendorId}/comments`; + // API 엔드포인트 구성 (techsales API 경로) + const url = `/api/tech-sales-rfqs/${params.rfqId}/vendors/${params.vendorId}/comments`; + + console.log("API 요청 시작:", { url, params }); // API 호출 return fetch(url, { @@ -113,22 +160,65 @@ export function sendVendorCommentClient(params: { 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 || '코멘트 전송 중 오류가 발생했습니다'); } - return result.data.comment; + + // 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, @@ -139,7 +229,7 @@ export function BuyerCommunicationDrawer({ const { data: session } = useSession(); // 상태 관리 - const [comments, setComments] = useState<Comment[]>([]); + const [comments, setComments] = useState<TechSalesComment[]>([]); const [newComment, setNewComment] = useState(""); const [attachments, setAttachments] = useState<File[]>([]); const [isLoading, setIsLoading] = useState(false); @@ -149,7 +239,7 @@ export function BuyerCommunicationDrawer({ // 첨부파일 관련 상태 const [previewDialogOpen, setPreviewDialogOpen] = useState(false); - const [selectedAttachment, setSelectedAttachment] = useState<Attachment | null>(null); + const [selectedAttachment, setSelectedAttachment] = useState<TechSalesAttachment | null>(null); // 드로어가 열릴 때 데이터 로드 useEffect(() => { @@ -173,7 +263,7 @@ export function BuyerCommunicationDrawer({ setIsLoading(true); // API를 사용하여 코멘트 데이터 가져오기 - const commentsData = await fetchBuyerVendorComments(quotation.rfqId, quotation.vendorId); + const commentsData = await fetchTechSalesVendorCommentsClient(quotation.rfqId, quotation.vendorId); setComments(commentsData); // 읽음 상태 처리는 API 측에서 처리되는 것으로 가정 @@ -239,19 +329,20 @@ export function BuyerCommunicationDrawer({ }; // 첨부파일 미리보기 - const handleAttachmentPreview = (attachment: Attachment) => { + const handleAttachmentPreview = (attachment: TechSalesAttachment) => { setSelectedAttachment(attachment); setPreviewDialogOpen(true); }; // 첨부파일 다운로드 - const handleAttachmentDownload = (attachment: Attachment) => { + const handleAttachmentDownload = (attachment: TechSalesAttachment) => { // 실제 다운로드 구현 window.open(attachment.filePath, '_blank'); }; // 파일 아이콘 선택 - const getFileIcon = (fileType: string) => { + 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")) @@ -265,8 +356,8 @@ export function BuyerCommunicationDrawer({ const renderAttachmentPreviewDialog = () => { if (!selectedAttachment) return null; - const isImage = selectedAttachment.fileType.startsWith("image/"); - const isPdf = selectedAttachment.fileType.includes("pdf"); + const isImage = selectedAttachment.fileType?.startsWith("image/") || false; + const isPdf = selectedAttachment.fileType?.includes("pdf") || false; return ( <Dialog open={previewDialogOpen} onOpenChange={setPreviewDialogOpen}> diff --git a/lib/techsales-rfq/vendor-response/detail/communication-tab.tsx b/lib/techsales-rfq/vendor-response/detail/communication-tab.tsx index 0332232c..3f2a5280 100644 --- a/lib/techsales-rfq/vendor-response/detail/communication-tab.tsx +++ b/lib/techsales-rfq/vendor-response/detail/communication-tab.tsx @@ -1,21 +1,22 @@ "use client" import * as React from "react" -import { useState } from "react" +import { useState, useEffect } from "react" import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card" import { Button } from "@/components/ui/button" -import { Textarea } from "@/components/ui/textarea" import { Badge } from "@/components/ui/badge" import { ScrollArea } from "@/components/ui/scroll-area" -import { Separator } from "@/components/ui/separator" -import { Avatar, AvatarFallback } from "@/components/ui/avatar" -import { Send, MessageCircle } from "lucide-react" -import { formatDateTime } from "@/lib/utils" -import { toast } from "sonner" +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 @@ -31,100 +32,73 @@ interface CommunicationTabProps { } } -// 임시 코멘트 데이터 (실제로는 API에서 가져와야 함) -const MOCK_COMMENTS = [ - { - id: 1, - content: "안녕하세요. 해당 자재에 대한 견적 요청 드립니다. 납기일은 언제까지 가능한지 문의드립니다.", - createdAt: new Date("2024-01-15T09:00:00"), - author: { - name: "김구매", - email: "buyer@company.com", - role: "구매담당자" - } - }, - { - id: 2, - content: "안녕하세요. 견적 요청 확인했습니다. 해당 자재의 경우 약 2주 정도의 제작 기간이 필요합니다. 상세한 견적은 내일까지 제출하겠습니다.", - createdAt: new Date("2024-01-15T14:30:00"), - author: { - name: "이벤더", - email: "vendor@supplier.com", - role: "벤더" - } - }, - { - id: 3, - content: "감사합니다. 추가로 품질 인증서도 함께 제출 가능한지 확인 부탁드립니다.", - createdAt: new Date("2024-01-16T10:15:00"), - author: { - name: "김구매", - email: "buyer@company.com", - role: "구매담당자" - } - } -] - export function CommunicationTab({ quotation }: CommunicationTabProps) { - const [newComment, setNewComment] = useState("") - const [isLoading, setIsLoading] = useState(false) - const [comments, setComments] = useState(MOCK_COMMENTS) + const [comments, setComments] = useState<TechSalesComment[]>([]); + const [unreadCount, setUnreadCount] = useState(0); + const [loadingComments, setLoadingComments] = useState(false); + const [communicationDrawerOpen, setCommunicationDrawerOpen] = useState(false); - const handleSendComment = async () => { - if (!newComment.trim()) { - toast.error("메시지를 입력해주세요.") - return + // 컴포넌트 마운트 시 메시지 미리 로드 + useEffect(() => { + if (quotation) { + loadCommunicationData(); } + }, [quotation]); - setIsLoading(true) + // 메시지 데이터 로드 함수 + const loadCommunicationData = async () => { try { - // TODO: API 호출로 코멘트 전송 - const newCommentData = { - id: comments.length + 1, - content: newComment, - createdAt: new Date(), - author: { - name: "현재사용자", // 실제로는 세션에서 가져와야 함 - email: "current@user.com", - role: "벤더" - } - } - - setComments([...comments, newCommentData]) - setNewComment("") - toast.success("메시지가 전송되었습니다.") - } catch { - toast.error("메시지 전송 중 오류가 발생했습니다.") + 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 { - setIsLoading(false) + setLoadingComments(false); } - } + }; - const getAuthorInitials = (name: string) => { - return name - .split(" ") - .map(word => word[0]) - .join("") - .toUpperCase() - .slice(0, 2) - } - - const getRoleBadgeVariant = (role: string) => { - return role === "구매담당자" ? "default" : "secondary" - } + // 커뮤니케이션 드로어가 닫힐 때 데이터 새로고침 + const handleCommunicationDrawerChange = (open: boolean) => { + setCommunicationDrawerOpen(open); + if (!open) { + loadCommunicationData(); // 드로어가 닫힐 때 데이터 새로고침 + } + }; return ( <div className="h-full flex flex-col"> {/* 헤더 */} <Card className="mb-4"> - <CardHeader> - <CardTitle className="flex items-center gap-2"> - <MessageCircle className="h-5 w-5" /> - 커뮤니케이션 - </CardTitle> - <CardDescription> - RFQ {quotation.rfq?.rfqCode || "미할당"}에 대한 구매담당자와의 커뮤니케이션 - </CardDescription> + <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"> @@ -135,81 +109,101 @@ export function CommunicationTab({ quotation }: CommunicationTabProps) { </CardContent> </Card> - {/* 메시지 목록 */} + {/* 메시지 미리보기 */} <Card className="flex-1 flex flex-col min-h-0"> <CardHeader> <CardTitle className="text-lg">메시지 ({comments.length})</CardTitle> </CardHeader> - <CardContent className="flex-1 flex flex-col min-h-0"> - <ScrollArea className="flex-1 pr-4"> - <div className="space-y-4"> - {comments.length === 0 ? ( - <div className="text-center py-8 text-muted-foreground"> - <MessageCircle className="h-12 w-12 mx-auto mb-4 opacity-50" /> - <p>아직 메시지가 없습니다.</p> - <p className="text-sm">첫 번째 메시지를 보내보세요.</p> + <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> - ) : ( - comments.map((comment) => ( - <div key={comment.id} className="flex gap-3"> - <Avatar className="h-8 w-8 mt-1"> - <AvatarFallback className="text-xs"> - {getAuthorInitials(comment.author.name)} - </AvatarFallback> - </Avatar> - <div className="flex-1 space-y-2"> - <div className="flex items-center gap-2"> - <span className="font-medium text-sm">{comment.author.name}</span> - <Badge variant={getRoleBadgeVariant(comment.author.role)} className="text-xs"> - {comment.author.role} - </Badge> + <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"> - {formatDateTime(comment.createdAt)} + {new Date(comment.createdAt).toLocaleDateString()} </span> </div> - <div className="bg-muted p-3 rounded-lg text-sm"> - {comment.content} - </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> - </div> - )) - )} - </div> - </ScrollArea> - - <Separator className="my-4" /> + ))} + </ScrollArea> + </div> - {/* 새 메시지 입력 */} - <div className="space-y-3"> - <Textarea - placeholder="메시지를 입력하세요..." - value={newComment} - onChange={(e) => setNewComment(e.target.value)} - rows={3} - className="resize-none" - onKeyDown={(e) => { - if (e.key === "Enter" && (e.ctrlKey || e.metaKey)) { - e.preventDefault() - handleSendComment() - } - }} - /> - <div className="flex justify-between items-center"> - <div className="text-xs text-muted-foreground"> - Ctrl + Enter로 빠른 전송 + <div className="flex justify-center"> + <Button + onClick={() => setCommunicationDrawerOpen(true)} + className="w-full" + > + 전체 메시지 보기 ({comments.length}개) + </Button> </div> - <Button - onClick={handleSendComment} - disabled={isLoading || !newComment.trim()} - size="sm" - > - <Send className="h-4 w-4 mr-2" /> - 전송 - </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/table/vendor-quotations-table-columns.tsx b/lib/techsales-rfq/vendor-response/table/vendor-quotations-table-columns.tsx index 5c6971cc..109698ea 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 @@ -2,7 +2,7 @@ import * as React from "react" import { type ColumnDef } from "@tanstack/react-table" -import { Edit } from "lucide-react" +import { Edit, Paperclip } from "lucide-react" import { formatCurrency, formatDate, formatDateTime } from "@/lib/utils" import { Badge } from "@/components/ui/badge" import { Button } from "@/components/ui/button" @@ -31,13 +31,15 @@ interface QuotationWithRfqCode extends TechSalesVendorQuotations { quotationVersion?: number | null; rejectionReason?: string | null; acceptedAt?: Date | null; + attachmentCount?: number; } interface GetColumnsProps { router: AppRouterInstance; + openAttachmentsSheet: (rfqId: number) => void; } -export function getColumns({ router }: GetColumnsProps): ColumnDef<QuotationWithRfqCode>[] { +export function getColumns({ router, openAttachmentsSheet }: GetColumnsProps): ColumnDef<QuotationWithRfqCode>[] { return [ { id: "select", @@ -163,6 +165,55 @@ export function getColumns({ router }: GetColumnsProps): ColumnDef<QuotationWith enableHiding: true, }, { + id: "attachments", + header: ({ column }) => ( + <DataTableColumnHeader 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 }) => ( <DataTableColumnHeader column={column} title="상태" /> 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 63d4674b..e1b82579 100644 --- a/lib/techsales-rfq/vendor-response/table/vendor-quotations-table.tsx +++ b/lib/techsales-rfq/vendor-response/table/vendor-quotations-table.tsx @@ -9,6 +9,9 @@ import { DataTableAdvancedToolbar } from "@/components/data-table/data-table-adv 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 { getTechSalesRfqAttachments } from "@/lib/techsales-rfq/service" +import { toast } from "sonner" interface QuotationWithRfqCode extends TechSalesVendorQuotations { rfqCode?: string; @@ -18,13 +21,14 @@ interface QuotationWithRfqCode extends TechSalesVendorQuotations { itemName?: string; projNm?: string; quotationCode?: string | null; - quotationVersion?: number | null; + quotationVersion: number | null; rejectionReason?: string | null; acceptedAt?: Date | null; + attachmentCount?: number; } interface VendorQuotationsTableProps { - promises: Promise<[{ data: any[], pageCount: number, total?: number }]>; + promises: Promise<[{ data: QuotationWithRfqCode[], pageCount: number, total?: number }]>; } export function VendorQuotationsTable({ promises }: VendorQuotationsTableProps) { @@ -34,16 +38,68 @@ export function VendorQuotationsTable({ promises }: VendorQuotationsTableProps) const [{ data, pageCount }] = React.use(promises); 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 stableData = React.useMemo(() => { return data; }, [data.length, data.map(item => `${item.id}-${item.status}-${item.updatedAt}`).join(',')]); + // 첨부파일 시트 열기 함수 + 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 형식으로 변환 + const attachments: ExistingTechSalesAttachment[] = result.data.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" | "VENDOR_SPECIFIC", + 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]) + // 테이블 컬럼 정의 - router는 안정적이므로 한 번만 생성 const columns = React.useMemo(() => getColumns({ router, - }), [router]); + openAttachmentsSheet, + }), [router, openAttachmentsSheet]); // 필터 필드 - 중앙화된 상태 상수 사용 const filterFields = React.useMemo<DataTableFilterField<QuotationWithRfqCode>[]>(() => [ @@ -138,6 +194,16 @@ export function VendorQuotationsTable({ promises }: VendorQuotationsTableProps) </DataTableAdvancedToolbar> </DataTable> </div> + + {/* 첨부파일 관리 시트 (읽기 전용) */} + <TechSalesRfqAttachmentsSheet + open={attachmentsOpen} + onOpenChange={setAttachmentsOpen} + defaultAttachments={attachmentsDefault} + rfq={selectedRfqForAttachments} + onAttachmentsUpdated={() => {}} // 읽기 전용이므로 빈 함수 + readOnly={true} // 벤더 쪽에서는 항상 읽기 전용 + /> </div> ); }
\ No newline at end of file |
