summaryrefslogtreecommitdiff
path: root/lib/techsales-rfq/vendor-response
diff options
context:
space:
mode:
Diffstat (limited to 'lib/techsales-rfq/vendor-response')
-rw-r--r--lib/techsales-rfq/vendor-response/buyer-communication-drawer.tsx165
-rw-r--r--lib/techsales-rfq/vendor-response/detail/communication-tab.tsx292
-rw-r--r--lib/techsales-rfq/vendor-response/table/vendor-quotations-table-columns.tsx55
-rw-r--r--lib/techsales-rfq/vendor-response/table/vendor-quotations-table.tsx72
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