summaryrefslogtreecommitdiff
path: root/lib/procurement-rfqs/vendor-response
diff options
context:
space:
mode:
authordujinkim <dujin.kim@dtsolution.co.kr>2025-05-12 11:36:25 +0000
committerdujinkim <dujin.kim@dtsolution.co.kr>2025-05-12 11:36:25 +0000
commita6b9cdaf9ea5ed548292632f821e36453f377a83 (patch)
tree1c07b92b4173bfe3a12eedba7188fba8dc6f94cb /lib/procurement-rfqs/vendor-response
parentdf91418cd28e98ce05845e476e51aa810202bf33 (diff)
(대표님) procurement-rfq 작업업
Diffstat (limited to 'lib/procurement-rfqs/vendor-response')
-rw-r--r--lib/procurement-rfqs/vendor-response/buyer-communication-drawer.tsx522
-rw-r--r--lib/procurement-rfqs/vendor-response/quotation-editor.tsx953
-rw-r--r--lib/procurement-rfqs/vendor-response/quotation-item-editor.tsx664
-rw-r--r--lib/procurement-rfqs/vendor-response/table/vendor-quotations-table-columns.tsx239
-rw-r--r--lib/procurement-rfqs/vendor-response/table/vendor-quotations-table.tsx145
5 files changed, 2523 insertions, 0 deletions
diff --git a/lib/procurement-rfqs/vendor-response/buyer-communication-drawer.tsx b/lib/procurement-rfqs/vendor-response/buyer-communication-drawer.tsx
new file mode 100644
index 00000000..69ba0363
--- /dev/null
+++ b/lib/procurement-rfqs/vendor-response/buyer-communication-drawer.tsx
@@ -0,0 +1,522 @@
+"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 { ScrollArea } from "@/components/ui/scroll-area"
+import {
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogHeader,
+ DialogTitle,
+} 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 허용으로 변경
+}
+
+interface Attachment {
+ id: number;
+ fileName: string;
+ fileSize: number;
+ fileType: string | null; // null 허용으로 변경
+ filePath: string;
+ uploadedAt: Date;
+}
+
+// 프롭스 정의
+interface BuyerCommunicationDrawerProps {
+ open: boolean;
+ onOpenChange: (open: boolean) => void;
+ quotation: {
+ id: number;
+ rfqId: number;
+ vendorId: number;
+ quotationCode: string;
+ rfq?: {
+ rfqCode: string;
+ };
+ } | null;
+ onSuccess?: () => void;
+}
+
+
+
+// 벤더 코멘트 전송 함수
+export function sendVendorCommentClient(params: {
+ rfqId: number;
+ vendorId: number;
+ content: string;
+ attachments?: File[];
+}): Promise<Comment> {
+ // 폼 데이터 생성 (파일 첨부를 위해)
+ 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 엔드포인트 구성 (벤더 API 경로)
+ const url = `/api/procurement-rfqs/${params.rfqId}/vendors/${params.vendorId}/comments`;
+
+ // API 호출
+ return fetch(url, {
+ method: 'POST',
+ body: formData, // multipart/form-data 형식 사용
+ })
+ .then(response => {
+ if (!response.ok) {
+ return response.text().then(text => {
+ throw new Error(`API 요청 실패: ${response.status} ${text}`);
+ });
+ }
+ return response.json();
+ })
+ .then(result => {
+ if (!result.success || !result.data) {
+ throw new Error(result.message || '코멘트 전송 중 오류가 발생했습니다');
+ }
+ return result.data.comment;
+ });
+}
+
+
+export function BuyerCommunicationDrawer({
+ open,
+ onOpenChange,
+ quotation,
+ onSuccess
+}: BuyerCommunicationDrawerProps) {
+ // 세션 정보
+ const { data: session } = useSession();
+
+ // 상태 관리
+ const [comments, setComments] = useState<Comment[]>([]);
+ const [newComment, setNewComment] = useState("");
+ const [attachments, setAttachments] = useState<File[]>([]);
+ const [isLoading, setIsLoading] = useState(false);
+ const [isSubmitting, setIsSubmitting] = useState(false);
+ const fileInputRef = useRef<HTMLInputElement>(null);
+ const messagesEndRef = useRef<HTMLDivElement>(null);
+
+ // 첨부파일 관련 상태
+ const [previewDialogOpen, setPreviewDialogOpen] = useState(false);
+ const [selectedAttachment, setSelectedAttachment] = useState<Attachment | null>(null);
+
+ // 드로어가 열릴 때 데이터 로드
+ useEffect(() => {
+ if (open && quotation) {
+ loadComments();
+ }
+ }, [open, quotation]);
+
+ // 스크롤 최하단으로 이동
+ useEffect(() => {
+ if (messagesEndRef.current) {
+ messagesEndRef.current.scrollIntoView({ behavior: "smooth" });
+ }
+ }, [comments]);
+
+ // 코멘트 로드 함수
+ const loadComments = async () => {
+ if (!quotation) return;
+
+ try {
+ setIsLoading(true);
+
+ // API를 사용하여 코멘트 데이터 가져오기
+ const commentsData = await fetchBuyerVendorComments(quotation.rfqId, quotation.vendorId);
+ setComments(commentsData);
+
+ // 읽음 상태 처리는 API 측에서 처리되는 것으로 가정
+ } catch (error) {
+ console.error("코멘트 로드 오류:", error);
+ toast.error("메시지를 불러오는 중 오류가 발생했습니다");
+ } finally {
+ setIsLoading(false);
+ }
+ };
+
+ // 파일 선택 핸들러
+ 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: Attachment) => {
+ setSelectedAttachment(attachment);
+ setPreviewDialogOpen(true);
+ };
+
+ // 첨부파일 다운로드
+ const handleAttachmentDownload = (attachment: Attachment) => {
+ // 실제 다운로드 구현
+ window.open(attachment.filePath, '_blank');
+ };
+
+ // 파일 아이콘 선택
+ const getFileIcon = (fileType: string) => {
+ if (fileType.startsWith("image/")) return <ImageIcon className="h-5 w-5 text-blue-500" />;
+ if (fileType.includes("pdf")) return <FileText className="h-5 w-5 text-red-500" />;
+ if (fileType.includes("spreadsheet") || fileType.includes("excel"))
+ return <FileText className="h-5 w-5 text-green-500" />;
+ if (fileType.includes("document") || fileType.includes("word"))
+ return <FileText className="h-5 w-5 text-blue-500" />;
+ return <File className="h-5 w-5 text-gray-500" />;
+ };
+
+ // 첨부파일 미리보기 다이얼로그
+ const renderAttachmentPreviewDialog = () => {
+ if (!selectedAttachment) return null;
+
+ const isImage = selectedAttachment.fileType.startsWith("image/");
+ const isPdf = selectedAttachment.fileType.includes("pdf");
+
+ return (
+ <Dialog open={previewDialogOpen} onOpenChange={setPreviewDialogOpen}>
+ <DialogContent className="max-w-3xl">
+ <DialogHeader>
+ <DialogTitle className="flex items-center gap-2">
+ {getFileIcon(selectedAttachment.fileType)}
+ {selectedAttachment.fileName}
+ </DialogTitle>
+ <DialogDescription>
+ {formatFileSize(selectedAttachment.fileSize)} • {formatDateTime(selectedAttachment.uploadedAt)}
+ </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-[85vh]">
+ <DrawerHeader className="border-b">
+ <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="p-0 flex flex-col h-[60vh]">
+ {/* 메시지 목록 */}
+ <ScrollArea className="flex-1 p-4">
+ {isLoading ? (
+ <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">
+ {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>
+ )}
+ </ScrollArea>
+
+ {/* 선택된 첨부파일 표시 */}
+ {attachments.length > 0 && (
+ <div className="p-2 bg-muted mx-4 rounded-md mb-2">
+ <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">
+ <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">
+ <div className="flex justify-between">
+ <Button variant="outline" onClick={() => loadComments()}>
+ 새로고침
+ </Button>
+ <DrawerClose asChild>
+ <Button variant="outline">닫기</Button>
+ </DrawerClose>
+ </div>
+ </DrawerFooter>
+ </DrawerContent>
+
+ {renderAttachmentPreviewDialog()}
+ </Drawer>
+ );
+} \ No newline at end of file
diff --git a/lib/procurement-rfqs/vendor-response/quotation-editor.tsx b/lib/procurement-rfqs/vendor-response/quotation-editor.tsx
new file mode 100644
index 00000000..963c2f85
--- /dev/null
+++ b/lib/procurement-rfqs/vendor-response/quotation-editor.tsx
@@ -0,0 +1,953 @@
+"use client"
+
+import * as React from "react"
+import { useState, useEffect, useMemo } from "react"
+import { useForm } from "react-hook-form"
+import { zodResolver } from "@hookform/resolvers/zod"
+import * as z from "zod"
+import { format } from "date-fns"
+import { toast } from "sonner"
+import { MessageSquare, Paperclip } from "lucide-react"
+
+import { Button } from "@/components/ui/button"
+import {
+ Form,
+ FormControl,
+ FormDescription,
+ FormField,
+ FormItem,
+ FormLabel,
+ FormMessage,
+} from "@/components/ui/form"
+import { Input } from "@/components/ui/input"
+import { Textarea } from "@/components/ui/textarea"
+import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
+import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from "@/components/ui/card"
+import { Badge } from "@/components/ui/badge"
+import { Separator } from "@/components/ui/separator"
+import { ScrollArea } from "@/components/ui/scroll-area"
+import { DatePicker } from "@/components/ui/date-picker"
+import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
+import { ClientDataTable } from "@/components/client-data-table/data-table"
+import { Skeleton } from "@/components/ui/skeleton"
+
+import { formatCurrency, formatDate } from "@/lib/utils"
+import { QuotationItemEditor } from "./quotation-item-editor"
+import {
+ submitVendorQuotation,
+ updateVendorQuotation,
+ fetchCurrencies,
+ fetchPaymentTerms,
+ fetchIncoterms,
+ fetchBuyerVendorComments,
+ Comment
+} from "../services"
+import { BuyerCommunicationDrawer } from "./buyer-communication-drawer"
+
+// 견적서 폼 스키마
+const quotationFormSchema = z.object({
+ quotationVersion: z.number().min(1),
+ // 필수값 표시됨
+ currency: z.string().min(1, "통화를 선택해주세요"),
+ // 필수값 표시됨
+ validUntil: z.date({
+ required_error: "견적 유효기간을 선택해주세요",
+ invalid_type_error: "유효한 날짜를 선택해주세요",
+ }),
+ // 필수값 표시됨
+ estimatedDeliveryDate: z.date({
+ required_error: "예상 납품일을 선택해주세요",
+ invalid_type_error: "유효한 날짜를 선택해주세요",
+ }),
+ // 필수값 표시됨
+ paymentTermsCode: z.string({
+ required_error: "지불 조건을 선택해주세요",
+ }).min(1, "지불 조건을 선택해주세요"),
+ // 필수값 표시됨
+ incotermsCode: z.string({
+ required_error: "인코텀즈를 선택해주세요",
+ }).min(1, "인코텀즈를 선택해주세요"),
+ // 필수값 아님
+ incotermsDetail: z.string().optional(),
+ // 필수값 아님
+ remark: z.string().optional(),
+})
+
+type QuotationFormValues = z.infer<typeof quotationFormSchema>
+
+// 데이터 타입 정의
+interface Currency {
+ code: string
+ name: string
+}
+
+interface PaymentTerm {
+ code: string
+ description: string
+}
+
+interface Incoterm {
+ code: string
+ description: string
+}
+
+// 이 컴포넌트에 전달되는 견적서 데이터 타입
+interface VendorQuotation {
+ id: number
+ rfqId: number
+ vendorId: number
+ quotationCode: string | null
+ quotationVersion: number | null
+ totalItemsCount: number | null
+ subTotal: string| null
+ taxTotal: string| null
+ discountTotal: string| null
+ totalPrice: string| null
+ currency: string| null
+ validUntil: Date | null
+ estimatedDeliveryDate: Date | null
+ paymentTermsCode: string | null
+ incotermsCode: string | null
+ incotermsDetail: string | null
+ status: "Draft" | "Submitted" | "Revised" | "Rejected" | "Accepted"
+ remark: string | null
+ rejectionReason: string | null
+ submittedAt: Date | null
+ acceptedAt: Date | null
+ createdAt: Date
+ updatedAt: Date
+ rfq: {
+ id: number
+ rfqCode: string| null
+ dueDate: Date | null
+ status: string| null
+ // 기타 필요한 정보
+ }
+ vendor: {
+ id: number
+ vendorName: string
+ vendorCode: string| null
+ // 기타 필요한 정보
+ }
+ items: QuotationItem[]
+}
+
+// 견적 아이템 타입
+interface QuotationItem {
+ id: number
+ quotationId: number
+ prItemId: number
+ materialCode: string | null
+ materialDescription: string | null
+ quantity: number
+ uom: string | null
+ unitPrice: number
+ totalPrice: number
+ currency: string
+ vendorMaterialCode: string | null
+ vendorMaterialDescription: string | null
+ deliveryDate: Date | null
+ leadTimeInDays: number | null
+ taxRate: number | null
+ taxAmount: number | null
+ discountRate: number | null
+ discountAmount: number | null
+ remark: string | null
+ isAlternative: boolean
+ isRecommended: boolean
+ createdAt: Date
+ updatedAt: Date
+ prItem?: {
+ id: number
+ materialCode: string | null
+ materialDescription: string | null
+ // 기타 필요한 정보
+ }
+}
+
+// 견적서 편집 컴포넌트 프롭스
+interface VendorQuotationEditorProps {
+ quotation: VendorQuotation
+}
+
+export default function VendorQuotationEditor({ quotation }: VendorQuotationEditorProps) {
+
+
+ const [activeTab, setActiveTab] = useState("items")
+ const [isSubmitting, setIsSubmitting] = useState(false)
+ const [isSaving, setIsSaving] = useState(false)
+ const [items, setItems] = useState<QuotationItem[]>(quotation.items || [])
+
+ // 서버에서 가져온 데이터 상태
+ const [currencies, setCurrencies] = useState<Currency[]>([])
+ const [paymentTerms, setPaymentTerms] = useState<PaymentTerm[]>([])
+ const [incoterms, setIncoterms] = useState<Incoterm[]>([])
+
+ // 데이터 로딩 상태
+ const [loadingCurrencies, setLoadingCurrencies] = useState(true)
+ const [loadingPaymentTerms, setLoadingPaymentTerms] = useState(true)
+ const [loadingIncoterms, setLoadingIncoterms] = useState(true)
+
+ // 커뮤니케이션 드로어 상태
+ const [communicationDrawerOpen, setCommunicationDrawerOpen] = useState(false)
+
+ const [comments, setComments] = useState<Comment[]>([]);
+ const [unreadCount, setUnreadCount] = useState(0);
+ const [loadingComments, setLoadingComments] = useState(false);
+
+ // 컴포넌트 마운트 시 메시지 미리 로드
+ useEffect(() => {
+ if (quotation) {
+ loadCommunicationData();
+ }
+ }, [quotation]);
+
+ // 메시지 데이터 로드 함수
+ const loadCommunicationData = async () => {
+ try {
+ setLoadingComments(true);
+ const commentsData = await fetchBuyerVendorComments(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(); // 드로어가 닫힐 때 데이터 새로고침
+ }
+ };
+
+ // 버튼 비활성화
+ const isBeforeDueDate = () => {
+ if (!quotation.rfq.dueDate) {
+ // dueDate가 null인 경우 기본적으로 수정 불가능하도록 설정 (false 반환)
+ return false;
+ }
+
+ const now = new Date();
+ const dueDate = new Date(quotation.rfq.dueDate);
+ return now < dueDate;
+ };
+ // 수정된 isDisabled 조건
+ const isDisabled = (quotation.status === "Accepted") ||
+ ((quotation.status === "Submitted" || quotation.status === "Revised") &&
+ !isBeforeDueDate());
+
+
+ // 견적서 총합 계산
+ const totals = useMemo(() => {
+ const subTotal = items.reduce((sum, item) => sum + Number(item.totalPrice), 0)
+ const taxTotal = items.reduce((sum, item) => sum + (Number(item.taxAmount) || 0), 0)
+ const discountTotal = items.reduce((sum, item) => sum + (Number(item.discountAmount) || 0), 0)
+ const totalPrice = subTotal + taxTotal - discountTotal
+
+ return {
+ subTotal,
+ taxTotal,
+ discountTotal,
+ totalPrice
+ }
+ }, [items])
+
+ // 폼 설정
+ const form = useForm<QuotationFormValues>({
+ resolver: zodResolver(quotationFormSchema),
+ defaultValues: {
+ quotationVersion: quotation.quotationVersion || 0,
+ currency: quotation.currency || "KRW",
+ validUntil: quotation.validUntil || undefined,
+ estimatedDeliveryDate: quotation.estimatedDeliveryDate || undefined,
+ paymentTermsCode: quotation.paymentTermsCode || "",
+ incotermsCode: quotation.incotermsCode || "",
+ incotermsDetail: quotation.incotermsDetail || "",
+ remark: quotation.remark || "",
+ },
+ mode: "onChange", // 실시간 검증 활성화
+ })
+
+ // 마운트 시 데이터 로드
+ useEffect(() => {
+ // 통화 데이터 로드
+ const loadCurrencies = async () => {
+ try {
+ setLoadingCurrencies(true)
+ const result = await fetchCurrencies()
+ if (result.success) {
+ setCurrencies(result.data)
+ } else {
+ toast.error(result.message || "통화 데이터 로드 실패")
+ }
+ } catch (error) {
+ console.error("통화 데이터 로드 오류:", error)
+ toast.error("통화 데이터를 불러오는 중 오류가 발생했습니다")
+ } finally {
+ setLoadingCurrencies(false)
+ }
+ }
+
+ // 지불 조건 데이터 로드
+ const loadPaymentTerms = async () => {
+ try {
+ setLoadingPaymentTerms(true)
+ const result = await fetchPaymentTerms()
+ if (result.success) {
+ setPaymentTerms(result.data)
+ } else {
+ toast.error(result.message || "지불 조건 데이터 로드 실패")
+ }
+ } catch (error) {
+ console.error("지불 조건 데이터 로드 오류:", error)
+ toast.error("지불 조건 데이터를 불러오는 중 오류가 발생했습니다")
+ } finally {
+ setLoadingPaymentTerms(false)
+ }
+ }
+
+ // 인코텀즈 데이터 로드
+ const loadIncoterms = async () => {
+ try {
+ setLoadingIncoterms(true)
+ const result = await fetchIncoterms()
+ if (result.success) {
+ setIncoterms(result.data)
+ } else {
+ toast.error(result.message || "인코텀즈 데이터 로드 실패")
+ }
+ } catch (error) {
+ console.error("인코텀즈 데이터 로드 오류:", error)
+ toast.error("인코텀즈 데이터를 불러오는 중 오류가 발생했습니다")
+ } finally {
+ setLoadingIncoterms(false)
+ }
+ }
+
+ // 함수 호출
+ loadCurrencies()
+ loadPaymentTerms()
+ loadIncoterms()
+ }, [])
+
+ // 견적서 저장
+ const handleSave = async () => {
+ try {
+ setIsSaving(true)
+
+ // 기본 검증 (통화는 필수)
+ const validationResult = await form.trigger(['currency']);
+ if (!validationResult) {
+ toast.warning("통화는 필수 항목입니다");
+ return;
+ }
+
+ const values = form.getValues()
+
+ const result = await updateVendorQuotation({
+ id: quotation.id,
+ ...values,
+ subTotal: totals.subTotal.toString(),
+ taxTotal: totals.taxTotal.toString(),
+ discountTotal: totals.discountTotal.toString(),
+ totalPrice: totals.totalPrice.toString(),
+ totalItemsCount: items.length,
+ })
+
+ if (result.success) {
+ toast.success("견적서가 저장되었습니다")
+
+ // 견적서 제출 준비 상태 점검
+ const formValid = await form.trigger();
+ const itemsValid = !items.some(item => item.unitPrice <= 0 || !item.deliveryDate);
+ const alternativeItemsValid = !items.some(item =>
+ item.isAlternative && (!item.vendorMaterialDescription || !item.remark)
+ );
+
+ if (formValid && itemsValid && alternativeItemsValid) {
+ toast.info("모든 필수 정보가 입력되었습니다. '견적서 제출' 버튼을 클릭하여 제출하세요.");
+ } else {
+ const missingFields = [];
+ if (!formValid) missingFields.push("견적서 기본 정보");
+ if (!itemsValid) missingFields.push("견적 항목의 단가/납품일");
+ if (!alternativeItemsValid) missingFields.push("대체품 정보");
+
+ toast.info(`제출하기 전에 다음 정보를 입력해주세요: ${missingFields.join(', ')}`);
+ }
+ } else {
+ toast.error(result.message || "견적서 저장 중 오류가 발생했습니다")
+ }
+ } catch (error) {
+ console.error("견적서 저장 오류:", error)
+ toast.error("견적서 저장 중 오류가 발생했습니다")
+ } finally {
+ setIsSaving(false)
+ }
+ }
+
+ // 견적서 제출
+ const handleSubmit = async () => {
+ try {
+ setIsSubmitting(true)
+
+ // 1. 폼 스키마 검증 (기본 정보)
+ const formValid = await form.trigger();
+ if (!formValid) {
+ const formState = form.getFieldState("validUntil");
+ const estimatedDeliveryState = form.getFieldState("estimatedDeliveryDate");
+ const paymentTermsState = form.getFieldState("paymentTermsCode");
+ const incotermsState = form.getFieldState("incotermsCode");
+
+ // 주요 필드별 오류 메시지 표시
+ if (!form.getValues("validUntil")) {
+ toast.error("견적 유효기간을 선택해주세요");
+ } else if (!form.getValues("estimatedDeliveryDate")) {
+ toast.error("예상 납품일을 선택해주세요");
+ } else if (!form.getValues("paymentTermsCode")) {
+ toast.error("지불 조건을 선택해주세요");
+ } else if (!form.getValues("incotermsCode")) {
+ toast.error("인코텀즈를 선택해주세요");
+ } else {
+ toast.error("견적서 기본 정보를 모두 입력해주세요");
+ }
+
+ // 견적 정보 탭으로 이동
+ setActiveTab("details");
+ return;
+ }
+
+ // 2. 견적 항목 검증
+ const emptyItems = items.filter(item =>
+ item.unitPrice <= 0 || !item.deliveryDate
+ );
+
+ if (emptyItems.length > 0) {
+ toast.error(`${emptyItems.length}개 항목의 단가와 납품일을 입력해주세요`);
+ setActiveTab("items");
+ return;
+ }
+
+ // 3. 대체품 정보 검증
+ const invalidAlternativeItems = items.filter(item =>
+ item.isAlternative && (!item.vendorMaterialDescription || !item.remark)
+ );
+
+ if (invalidAlternativeItems.length > 0) {
+ toast.error(`${invalidAlternativeItems.length}개의 대체품 항목에 정보를 모두 입력해주세요`);
+ setActiveTab("items");
+ return;
+ }
+
+ // 모든 검증 통과 - 제출 진행
+ const values = form.getValues();
+
+ const result = await submitVendorQuotation({
+ id: quotation.id,
+ ...values,
+ subTotal: totals.subTotal.toString(),
+ taxTotal: totals.taxTotal.toString(),
+ discountTotal: totals.discountTotal.toString(),
+ totalPrice: totals.totalPrice.toString(),
+ totalItemsCount: items.length,
+ });
+
+ if (result.success && isBeforeDueDate()) {
+ toast.success("견적서가 제출되었습니다. 마감일 전까지 수정 가능합니다.");
+
+ // 페이지 새로고침
+ window.location.reload();
+ } else {
+ toast.error(result.message || "견적서 제출 중 오류가 발생했습니다");
+ }
+ } catch (error) {
+ console.error("견적서 제출 오류:", error);
+ toast.error("견적서 제출 중 오류가 발생했습니다");
+ } finally {
+ setIsSubmitting(false);
+ }
+ }
+
+ const isSubmitReady = () => {
+ // 폼 유효성
+ const formValid = !Object.keys(form.formState.errors).length;
+
+ // 항목 유효성
+ const itemsValid = !items.some(item =>
+ item.unitPrice <= 0 || !item.deliveryDate
+ );
+
+ // 대체품 유효성
+ const alternativeItemsValid = !items.some(item =>
+ item.isAlternative && (!item.vendorMaterialDescription || !item.remark)
+ );
+
+ // 유효하지 않은 항목 또는 대체품이 있으면 제출 불가
+ return formValid && itemsValid && alternativeItemsValid;
+ }
+
+ // 아이템 업데이트 핸들러
+ const handleItemsUpdate = (updatedItems: QuotationItem[]) => {
+ setItems(updatedItems)
+ }
+
+ // 상태에 따른 배지 색상
+ const getStatusBadge = (status: string) => {
+ switch (status) {
+ case "Draft":
+ return <Badge variant="outline">초안</Badge>
+ case "Submitted":
+ return <Badge variant="default">제출됨</Badge>
+ case "Revised":
+ return <Badge variant="secondary">수정됨</Badge>
+ case "Rejected":
+ return <Badge variant="destructive">반려됨</Badge>
+ case "Accepted":
+ return <Badge variant="default">승인됨</Badge>
+ default:
+ return <Badge>{status}</Badge>
+ }
+ }
+
+ // 셀렉트 로딩 상태 표시 컴포넌트
+ const SelectSkeleton = () => (
+ <div className="flex flex-col gap-2">
+ <Skeleton className="h-4 w-[40%]" />
+ <Skeleton className="h-10 w-full" />
+ </div>
+ )
+
+ return (
+ <div className="space-y-6">
+ <div className="flex justify-between items-start">
+ <div>
+ <h1 className="text-2xl font-bold tracking-tight">견적서 작성</h1>
+ <p className="text-muted-foreground">
+ RFQ 번호: {quotation.rfq.rfqCode} | 견적서 번호: {quotation.quotationCode}
+ </p>
+ {quotation.rfq.dueDate ? (
+ <p className={`text-sm ${isBeforeDueDate() ? 'text-green-600' : 'text-red-600'}`}>
+ 마감일: {formatDate(new Date(quotation.rfq.dueDate))}
+ {isBeforeDueDate()
+ ? ' (마감 전: 수정 가능)'
+ : ' (마감 됨: 수정 불가)'}
+ </p>
+ ) : (
+ <p className="text-sm text-amber-600">
+ 마감일이 설정되지 않았습니다
+ </p>
+ )}
+ </div>
+ <div className="flex items-center gap-2">
+ {getStatusBadge(quotation.status)}
+ {quotation.status === "Rejected" && (
+ <div className="text-sm text-destructive">
+ <span className="font-medium">반려 사유:</span> {quotation.rejectionReason || "사유 없음"}
+ </div>
+ )}
+ </div>
+ </div>
+
+ <Tabs value={activeTab} onValueChange={setActiveTab} className="w-full">
+ <TabsList>
+ <TabsTrigger value="items">견적 항목</TabsTrigger>
+ <TabsTrigger value="details">견적 정보</TabsTrigger>
+ <TabsTrigger value="communication">커뮤니케이션</TabsTrigger>
+ </TabsList>
+
+ {/* 견적 항목 탭 */}
+ <TabsContent value="items" className="p-0 pt-4">
+ <Card>
+ <CardHeader>
+ <CardTitle>견적 항목 정보</CardTitle>
+ <CardDescription>
+ 각 항목에 대한 가격, 납품일 등을 입력해주세요
+ </CardDescription>
+ </CardHeader>
+ <CardContent>
+ <QuotationItemEditor
+ items={items}
+ onItemsChange={handleItemsUpdate}
+ disabled={isDisabled}
+ currency={form.watch("currency")}
+ />
+ </CardContent>
+ <CardFooter className="flex justify-between border-t p-4">
+ <div className="space-y-1">
+ <div className="text-sm text-muted-foreground">
+ <span className="font-medium">소계:</span> {formatCurrency(totals.subTotal, quotation.currency)}
+ </div>
+ <div className="text-sm text-muted-foreground">
+ <span className="font-medium">세액:</span> {formatCurrency(totals.taxTotal, quotation.currency)}
+ </div>
+ <div className="text-sm text-muted-foreground">
+ <span className="font-medium">할인액:</span> {formatCurrency(totals.discountTotal, quotation.currency)}
+ </div>
+ <div className="text-base font-bold">
+ <span>총액:</span> {formatCurrency(totals.totalPrice, quotation.currency)}
+ </div>
+ </div>
+ <div className="flex space-x-2">
+ <Button
+ variant="outline"
+ onClick={handleSave}
+ disabled={isDisabled || isSaving}
+ >
+ {isSaving ? "저장 중..." : "저장"}
+ </Button>
+ <Button
+ onClick={handleSubmit}
+ disabled={isDisabled || isSubmitting || !isSubmitReady()}
+ >
+ {isSubmitting ? "제출 중..." : "견적서 제출"}
+ </Button>
+ </div>
+ </CardFooter>
+ </Card>
+ </TabsContent>
+
+ {/* 견적 정보 탭 */}
+ <TabsContent value="details" className="p-0 pt-4">
+ <Form {...form}>
+ <form className="space-y-6">
+ <Card>
+ <CardHeader>
+ <CardTitle>견적서 기본 정보</CardTitle>
+ <CardDescription>
+ 견적서의 일반 정보를 입력해주세요
+ </CardDescription>
+ </CardHeader>
+ <CardContent className="grid grid-cols-1 md:grid-cols-2 gap-6">
+ {/* 통화 필드 */}
+ {loadingCurrencies ? (
+ <SelectSkeleton />
+ ) : (
+ <FormField
+ control={form.control}
+ name="currency"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel className="flex items-center">
+ 통화
+ <span className="text-destructive ml-1">*</span>
+ </FormLabel>
+ <Select
+ value={field.value}
+ onValueChange={field.onChange}
+ disabled={isDisabled}
+ >
+ <FormControl>
+ <SelectTrigger>
+ <SelectValue placeholder="통화 선택" />
+ </SelectTrigger>
+ </FormControl>
+ <SelectContent>
+ {currencies.map((currency) => (
+ <SelectItem key={currency.code} value={currency.code}>
+ {currency.code} ({currency.name})
+ </SelectItem>
+ ))}
+ </SelectContent>
+ </Select>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+ )}
+
+ <FormField
+ control={form.control}
+ name="validUntil"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel className="flex items-center">
+ 견적 유효기간
+ <span className="text-destructive ml-1">*</span> {/* 필수값 표시 */}
+ </FormLabel>
+ <FormControl>
+ <DatePicker
+ date={field.value}
+ onSelect={field.onChange}
+ disabled={isDisabled}
+ />
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+
+ <FormField
+ control={form.control}
+ name="estimatedDeliveryDate"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel className="flex items-center">
+ 예상 납품일
+ <span className="text-destructive ml-1">*</span>
+ </FormLabel>
+ <FormControl>
+ <DatePicker
+ date={field.value}
+ onSelect={field.onChange}
+ disabled={isDisabled}
+ />
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+
+ {/* 지불 조건 필드 */}
+ {loadingPaymentTerms ? (
+ <SelectSkeleton />
+ ) : (
+ <FormField
+ control={form.control}
+ name="paymentTermsCode"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel className="flex items-center">
+ 지불 조건
+ <span className="text-destructive ml-1">*</span>
+ </FormLabel>
+ <Select
+ value={field.value || ""}
+ onValueChange={field.onChange}
+ disabled={isDisabled}
+ >
+ <FormControl>
+ <SelectTrigger>
+ <SelectValue placeholder="지불 조건 선택" />
+ </SelectTrigger>
+ </FormControl>
+ <SelectContent>
+ {paymentTerms.map((term) => (
+ <SelectItem key={term.code} value={term.code}>
+ {term.description}
+ </SelectItem>
+ ))}
+ </SelectContent>
+ </Select>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+ )}
+
+ {/* 인코텀즈 필드 */}
+ {loadingIncoterms ? (
+ <SelectSkeleton />
+ ) : (
+ <FormField
+ control={form.control}
+ name="incotermsCode"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel className="flex items-center">
+ 인코텀즈
+ <span className="text-destructive ml-1">*</span>
+ </FormLabel>
+ <Select
+ value={field.value || ""}
+ onValueChange={field.onChange}
+ disabled={isDisabled}
+ >
+ <FormControl>
+ <SelectTrigger>
+ <SelectValue placeholder="인코텀즈 선택" />
+ </SelectTrigger>
+ </FormControl>
+ <SelectContent>
+ {incoterms.map((term) => (
+ <SelectItem key={term.code} value={term.code}>
+ {term.code} ({term.description})
+ </SelectItem>
+ ))}
+ </SelectContent>
+ </Select>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+ )}
+
+ <FormField
+ control={form.control}
+ name="incotermsDetail"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel className="flex items-center">
+ 인코텀즈 상세
+ <span className="text-destructive ml-1"></span>
+ </FormLabel>
+ <FormControl>
+ <Input
+ placeholder="인코텀즈 상세 정보 입력"
+ {...field}
+ value={field.value || ""}
+ disabled={isDisabled}
+ />
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+
+ <FormField
+ control={form.control}
+ name="remark"
+ render={({ field }) => (
+ <FormItem className="col-span-2">
+ <FormLabel className="flex items-center">
+ 비고
+ <span className="text-destructive ml-1"></span>
+ </FormLabel>
+ <FormControl>
+ <Textarea
+ placeholder="추가 정보나 특이사항을 입력해주세요"
+ className="resize-none min-h-[100px]"
+ {...field}
+ value={field.value || ""}
+ disabled={isDisabled}
+ />
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+ </CardContent>
+ <CardFooter className="flex justify-end">
+ <Button
+ variant="outline"
+ onClick={handleSave}
+ disabled={isDisabled || isSaving}
+ >
+ {isSaving ? "저장 중..." : "저장"}
+ </Button>
+ </CardFooter>
+ </Card>
+ </form>
+ </Form>
+ </TabsContent>
+
+ {/* 커뮤니케이션 탭 */}
+ <TabsContent value="communication" className="p-0 pt-4">
+ <Card>
+ <CardHeader className="flex flex-row items-center justify-between">
+ <div>
+ <CardTitle className="flex items-center gap-2">
+ 커뮤니케이션
+ {unreadCount > 0 && (
+ <Badge variant="destructive" className="ml-2">
+ 새 메시지 {unreadCount}
+ </Badge>
+ )}
+ </CardTitle>
+ <CardDescription>
+ 구매자와의 메시지 및 첨부파일
+ </CardDescription>
+ </div>
+ <Button
+ onClick={() => setCommunicationDrawerOpen(true)}
+ variant="outline"
+ size="sm"
+ >
+ <MessageSquare className="h-4 w-4 mr-2" />
+ {unreadCount > 0 ? "새 메시지 확인" : "메시지 보내기"}
+ </Button>
+ </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={quotation}
+ onSuccess={loadCommunicationData}
+ />
+ </TabsContent>
+ </Tabs>
+ </div>
+ )
+} \ No newline at end of file
diff --git a/lib/procurement-rfqs/vendor-response/quotation-item-editor.tsx b/lib/procurement-rfqs/vendor-response/quotation-item-editor.tsx
new file mode 100644
index 00000000..e11864dc
--- /dev/null
+++ b/lib/procurement-rfqs/vendor-response/quotation-item-editor.tsx
@@ -0,0 +1,664 @@
+"use client"
+
+import * as React from "react"
+import { useState, useEffect, useRef } from "react"
+import { toast } from "sonner"
+import { format } from "date-fns"
+
+import { Button } from "@/components/ui/button"
+import { Input } from "@/components/ui/input"
+import { Checkbox } from "@/components/ui/checkbox"
+import { DatePicker } from "@/components/ui/date-picker"
+import {
+ Table,
+ TableBody,
+ TableCaption,
+ TableCell,
+ TableHead,
+ TableHeader,
+ TableRow
+} from "@/components/ui/table"
+import { Badge } from "@/components/ui/badge"
+import { ScrollArea } from "@/components/ui/scroll-area"
+import {
+ Tooltip,
+ TooltipContent,
+ TooltipProvider,
+ TooltipTrigger
+} from "@/components/ui/tooltip"
+import {
+ Info,
+ Clock,
+ CalendarIcon,
+ ClipboardCheck,
+ AlertTriangle,
+ CheckCircle2,
+ RefreshCw,
+ Save,
+ FileText,
+ Sparkles
+} from "lucide-react"
+
+import { formatCurrency } from "@/lib/utils"
+import { updateQuotationItem } from "../services"
+import { Textarea } from "@/components/ui/textarea"
+
+// 견적 아이템 타입
+interface QuotationItem {
+ id: number
+ quotationId: number
+ prItemId: number
+ materialCode: string | null
+ materialDescription: string | null
+ quantity: number
+ uom: string | null
+ unitPrice: number
+ totalPrice: number
+ currency: string
+ vendorMaterialCode: string | null
+ vendorMaterialDescription: string | null
+ deliveryDate: Date | null
+ leadTimeInDays: number | null
+ taxRate: number | null
+ taxAmount: number | null
+ discountRate: number | null
+ discountAmount: number | null
+ remark: string | null
+ isAlternative: boolean
+ isRecommended: boolean // 남겨두지만 UI에서는 사용하지 않음
+ createdAt: Date
+ updatedAt: Date
+ prItem?: {
+ id: number
+ materialCode: string | null
+ materialDescription: string | null
+ // 기타 필요한 정보
+ }
+}
+
+// debounce 함수 구현
+function debounce<T extends (...args: any[]) => any>(
+ func: T,
+ wait: number
+): (...args: Parameters<T>) => void {
+ let timeout: NodeJS.Timeout | null = null;
+
+ return function (...args: Parameters<T>) {
+ if (timeout) clearTimeout(timeout);
+ timeout = setTimeout(() => func(...args), wait);
+ };
+}
+
+interface QuotationItemEditorProps {
+ items: QuotationItem[]
+ onItemsChange: (items: QuotationItem[]) => void
+ disabled?: boolean
+ currency: string
+}
+
+export function QuotationItemEditor({
+ items,
+ onItemsChange,
+ disabled = false,
+ currency
+}: QuotationItemEditorProps) {
+ const [editingItem, setEditingItem] = useState<number | null>(null)
+ const [isSaving, setIsSaving] = useState(false)
+
+ // 저장이 필요한 항목들을 추적
+ const [pendingChanges, setPendingChanges] = useState<Set<number>>(new Set())
+
+ // 로컬 상태 업데이트 함수 - 화면에 즉시 반영하지만 서버에는 즉시 저장하지 않음
+ const updateLocalItem = <K extends keyof QuotationItem>(
+ index: number,
+ field: K,
+ value: QuotationItem[K]
+ ) => {
+ // 로컬 상태 업데이트
+ const updatedItems = [...items]
+ const item = { ...updatedItems[index] }
+
+ // 필드 업데이트
+ item[field] = value
+
+ // 대체품 체크 해제 시 관련 필드 초기화
+ if (field === 'isAlternative' && value === false) {
+ item.vendorMaterialCode = null;
+ item.vendorMaterialDescription = null;
+ item.remark = null;
+ }
+
+ // 단가나 수량이 변경되면 총액 계산
+ if (field === 'unitPrice' || field === 'quantity') {
+ item.totalPrice = Number(item.unitPrice) * Number(item.quantity)
+
+ // 세금이 있으면 세액 계산
+ if (item.taxRate) {
+ item.taxAmount = item.totalPrice * (item.taxRate / 100)
+ }
+
+ // 할인이 있으면 할인액 계산
+ if (item.discountRate) {
+ item.discountAmount = item.totalPrice * (item.discountRate / 100)
+ }
+ }
+
+ // 세율이 변경되면 세액 계산
+ if (field === 'taxRate') {
+ item.taxAmount = item.totalPrice * (value as number / 100)
+ }
+
+ // 할인율이 변경되면 할인액 계산
+ if (field === 'discountRate') {
+ item.discountAmount = item.totalPrice * (value as number / 100)
+ }
+
+ // 변경된 아이템으로 교체
+ updatedItems[index] = item
+
+ // 미저장 항목으로 표시
+ setPendingChanges(prev => new Set(prev).add(item.id))
+
+ // 부모 컴포넌트에 변경 사항 알림
+ onItemsChange(updatedItems)
+
+ // 저장 필요함을 표시
+ return item
+ }
+
+ // 서버에 저장하는 함수
+ const saveItemToServer = async (item: QuotationItem, field: keyof QuotationItem, value: any) => {
+ if (disabled) return
+
+ try {
+ setIsSaving(true)
+
+ const result = await updateQuotationItem({
+ id: item.id,
+ [field]: value,
+ totalPrice: item.totalPrice,
+ taxAmount: item.taxAmount ?? 0,
+ discountAmount: item.discountAmount ?? 0
+ })
+
+ // 저장 완료 후 pendingChanges에서 제거
+ setPendingChanges(prev => {
+ const newSet = new Set(prev)
+ newSet.delete(item.id)
+ return newSet
+ })
+
+ if (!result.success) {
+ toast.error(result.message || "항목 저장 중 오류가 발생했습니다")
+ }
+ } catch (error) {
+ console.error("항목 저장 오류:", error)
+ toast.error("항목 저장 중 오류가 발생했습니다")
+ } finally {
+ setIsSaving(false)
+ }
+ }
+
+ // debounce된 저장 함수
+ const debouncedSave = useRef(debounce(
+ (item: QuotationItem, field: keyof QuotationItem, value: any) => {
+ saveItemToServer(item, field, value)
+ },
+ 800 // 800ms 지연
+ )).current
+
+ // 견적 항목 업데이트 함수
+ const handleItemUpdate = (index: number, field: keyof QuotationItem, value: any) => {
+ const updatedItem = updateLocalItem(index, field, value)
+
+ // debounce를 통해 서버 저장 지연
+ if (!disabled) {
+ debouncedSave(updatedItem, field, value)
+ }
+ }
+
+ // 모든 변경 사항 저장
+ const saveAllChanges = async () => {
+ if (disabled || pendingChanges.size === 0) return
+
+ setIsSaving(true)
+ toast.info(`${pendingChanges.size}개 항목 저장 중...`)
+
+ try {
+ // 변경된 모든 항목 저장
+ for (const itemId of pendingChanges) {
+ const index = items.findIndex(item => item.id === itemId)
+ if (index !== -1) {
+ const item = items[index]
+ await updateQuotationItem({
+ id: item.id,
+ unitPrice: item.unitPrice,
+ totalPrice: item.totalPrice,
+ taxRate: item.taxRate ?? 0,
+ taxAmount: item.taxAmount ?? 0,
+ discountRate: item.discountRate ?? 0,
+ discountAmount: item.discountAmount ?? 0,
+ deliveryDate: item.deliveryDate,
+ leadTimeInDays: item.leadTimeInDays ?? 0,
+ vendorMaterialCode: item.vendorMaterialCode ?? "",
+ vendorMaterialDescription: item.vendorMaterialDescription ?? "",
+ isAlternative: item.isAlternative,
+ isRecommended: false, // 항상 false로 설정 (사용하지 않음)
+ remark: item.remark ?? ""
+ })
+ }
+ }
+
+ // 모든 변경 사항 저장 완료
+ setPendingChanges(new Set())
+ toast.success("모든 변경 사항이 저장되었습니다")
+ } catch (error) {
+ console.error("변경 사항 저장 오류:", error)
+ toast.error("변경 사항 저장 중 오류가 발생했습니다")
+ } finally {
+ setIsSaving(false)
+ }
+ }
+
+ // blur 이벤트로 저장 트리거 (사용자가 입력 완료 후)
+ const handleBlur = (index: number, field: keyof QuotationItem, value: any) => {
+ const itemId = items[index].id
+
+ // 해당 항목이 pendingChanges에 있다면 즉시 저장
+ if (pendingChanges.has(itemId)) {
+ const item = items[index]
+ saveItemToServer(item, field, value)
+ }
+ }
+
+ // 전체 단가 업데이트 (일괄 반영)
+ const handleBulkUnitPriceUpdate = () => {
+ if (items.length === 0) return
+
+ // 첫 번째 아이템의 단가 가져오기
+ const firstUnitPrice = items[0].unitPrice
+
+ if (!firstUnitPrice) {
+ toast.error("첫 번째 항목의 단가를 먼저 입력해주세요")
+ return
+ }
+
+ // 모든 아이템에 동일한 단가 적용
+ const updatedItems = items.map(item => ({
+ ...item,
+ unitPrice: firstUnitPrice,
+ totalPrice: firstUnitPrice * item.quantity,
+ taxAmount: item.taxRate ? (firstUnitPrice * item.quantity) * (item.taxRate / 100) : item.taxAmount,
+ discountAmount: item.discountRate ? (firstUnitPrice * item.quantity) * (item.discountRate / 100) : item.discountAmount
+ }))
+
+ // 모든 아이템을 변경 필요 항목으로 표시
+ setPendingChanges(new Set(updatedItems.map(item => item.id)))
+
+ // 부모 컴포넌트에 변경 사항 알림
+ onItemsChange(updatedItems)
+
+ toast.info("모든 항목의 단가가 업데이트되었습니다. 변경 사항을 저장하려면 '저장' 버튼을 클릭하세요.")
+ }
+
+ // 입력 핸들러
+ const handleNumberInputChange = (
+ index: number,
+ field: keyof QuotationItem,
+ e: React.ChangeEvent<HTMLInputElement>
+ ) => {
+ const value = e.target.value === '' ? 0 : parseFloat(e.target.value)
+ handleItemUpdate(index, field, value)
+ }
+
+ const handleTextInputChange = (
+ index: number,
+ field: keyof QuotationItem,
+ e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>
+ ) => {
+ handleItemUpdate(index, field, e.target.value)
+ }
+
+ const handleDateChange = (
+ index: number,
+ field: keyof QuotationItem,
+ date: Date | undefined
+ ) => {
+ handleItemUpdate(index, field, date || null)
+ }
+
+ const handleCheckboxChange = (
+ index: number,
+ field: keyof QuotationItem,
+ checked: boolean
+ ) => {
+ handleItemUpdate(index, field, checked)
+ }
+
+ // 날짜 형식 지정
+ const formatDeliveryDate = (date: Date | null) => {
+ if (!date) return "-"
+ return format(date, "yyyy-MM-dd")
+ }
+
+ // 입력 폼 필드 렌더링
+ const renderInputField = (item: QuotationItem, index: number, field: keyof QuotationItem) => {
+ if (field === 'unitPrice' || field === 'taxRate' || field === 'discountRate' || field === 'leadTimeInDays') {
+ return (
+ <Input
+ type="number"
+ min={0}
+ step={field === 'unitPrice' ? 0.01 : field === 'taxRate' || field === 'discountRate' ? 0.1 : 1}
+ value={item[field] as number || 0}
+ onChange={(e) => handleNumberInputChange(index, field, e)}
+ onBlur={(e) => handleBlur(index, field, parseFloat(e.target.value) || 0)}
+ disabled={disabled || isSaving}
+ className="w-full"
+ />
+ )
+ } else if (field === 'vendorMaterialCode' || field === 'vendorMaterialDescription') {
+ return (
+ <Input
+ type="text"
+ value={item[field] as string || ''}
+ onChange={(e) => handleTextInputChange(index, field, e)}
+ onBlur={(e) => handleBlur(index, field, e.target.value)}
+ disabled={disabled || isSaving || !item.isAlternative}
+ className="w-full"
+ placeholder={field === 'vendorMaterialCode' ? "벤더 자재코드" : "벤더 자재명"}
+ />
+ )
+ } else if (field === 'deliveryDate') {
+ return (
+ <DatePicker
+ date={item.deliveryDate ? new Date(item.deliveryDate) : undefined}
+ onSelect={(date) => {
+ handleDateChange(index, field, date);
+ // DatePicker는 blur 이벤트가 없으므로 즉시 저장 트리거
+ if (date) handleBlur(index, field, date);
+ }}
+ disabled={disabled || isSaving}
+ />
+ )
+ } else if (field === 'isAlternative') {
+ return (
+ <div className="flex items-center gap-1">
+ <Checkbox
+ checked={item.isAlternative}
+ onCheckedChange={(checked) => {
+ handleCheckboxChange(index, field, checked as boolean);
+ handleBlur(index, field, checked as boolean);
+ }}
+ disabled={disabled || isSaving}
+ />
+ <span className="text-xs">대체품</span>
+ </div>
+ )
+ }
+
+ return null
+ }
+
+ // 대체품 필드 렌더링
+ const renderAlternativeFields = (item: QuotationItem, index: number) => {
+ if (!item.isAlternative) return null;
+
+ return (
+ <div className="mt-2 p-3 bg-blue-50 rounded-md space-y-2 text-sm">
+ {/* <div className="flex flex-col gap-2">
+ <label className="text-xs font-medium text-blue-700">벤더 자재코드</label>
+ <Input
+ value={item.vendorMaterialCode || ""}
+ onChange={(e) => handleTextInputChange(index, 'vendorMaterialCode', e)}
+ onBlur={(e) => handleBlur(index, 'vendorMaterialCode', e.target.value)}
+ disabled={disabled || isSaving}
+ className="h-8 text-sm"
+ placeholder="벤더 자재코드 입력"
+ />
+ </div> */}
+
+ <div className="flex flex-col gap-2">
+ <label className="text-xs font-medium text-blue-700">벤더 자재명</label>
+ <Input
+ value={item.vendorMaterialDescription || ""}
+ onChange={(e) => handleTextInputChange(index, 'vendorMaterialDescription', e)}
+ onBlur={(e) => handleBlur(index, 'vendorMaterialDescription', e.target.value)}
+ disabled={disabled || isSaving}
+ className="h-8 text-sm"
+ placeholder="벤더 자재명 입력"
+ />
+ </div>
+
+ <div className="flex flex-col gap-2">
+ <label className="text-xs font-medium text-blue-700">대체품 설명</label>
+ <Textarea
+ value={item.remark || ""}
+ onChange={(e) => handleTextInputChange(index, 'remark', e)}
+ onBlur={(e) => handleBlur(index, 'remark', e.target.value)}
+ disabled={disabled || isSaving}
+ className="min-h-[60px] text-sm"
+ placeholder="원본과의 차이점, 대체 사유, 장점 등을 설명해주세요"
+ />
+ </div>
+ </div>
+ );
+ };
+
+ // 항목의 저장 상태 아이콘 표시
+ const renderSaveStatus = (itemId: number) => {
+ if (pendingChanges.has(itemId)) {
+ return (
+ <TooltipProvider>
+ <Tooltip>
+ <TooltipTrigger>
+ <RefreshCw className="h-4 w-4 text-yellow-500 animate-spin" />
+ </TooltipTrigger>
+ <TooltipContent>
+ <p>저장되지 않은 변경 사항이 있습니다</p>
+ </TooltipContent>
+ </Tooltip>
+ </TooltipProvider>
+ )
+ }
+
+ return null
+ }
+
+ return (
+ <div className="space-y-4">
+ <div className="flex justify-between items-center">
+ <div className="flex items-center gap-2">
+ <h3 className="text-lg font-medium">항목 목록 ({items.length}개)</h3>
+ {pendingChanges.size > 0 && (
+ <Badge variant="outline" className="bg-yellow-50">
+ 변경 {pendingChanges.size}개
+ </Badge>
+ )}
+ </div>
+
+ <div className="flex items-center gap-2">
+ {pendingChanges.size > 0 && !disabled && (
+ <Button
+ variant="default"
+ size="sm"
+ onClick={saveAllChanges}
+ disabled={isSaving}
+ >
+ {isSaving ? (
+ <RefreshCw className="h-4 w-4 mr-2 animate-spin" />
+ ) : (
+ <Save className="h-4 w-4 mr-2" />
+ )}
+ 변경사항 저장 ({pendingChanges.size}개)
+ </Button>
+ )}
+
+ {!disabled && (
+ <Button
+ variant="outline"
+ size="sm"
+ onClick={handleBulkUnitPriceUpdate}
+ disabled={items.length === 0 || isSaving}
+ >
+ 첫 항목 단가로 일괄 적용
+ </Button>
+ )}
+ </div>
+ </div>
+
+ <ScrollArea className="h-[500px] rounded-md border">
+ <Table>
+ <TableHeader className="sticky top-0 bg-background">
+ <TableRow>
+ <TableHead className="w-[50px]">번호</TableHead>
+ <TableHead>자재코드</TableHead>
+ <TableHead>자재명</TableHead>
+ <TableHead>수량</TableHead>
+ <TableHead>단위</TableHead>
+ <TableHead>단가</TableHead>
+ <TableHead>금액</TableHead>
+ <TableHead>
+ <div className="flex items-center gap-1">
+ 세율(%)
+ <TooltipProvider>
+ <Tooltip>
+ <TooltipTrigger>
+ <Info className="h-4 w-4" />
+ </TooltipTrigger>
+ <TooltipContent>
+ <p>세율을 입력하면 자동으로 세액이 계산됩니다.</p>
+ </TooltipContent>
+ </Tooltip>
+ </TooltipProvider>
+ </div>
+ </TableHead>
+ <TableHead>
+ <div className="flex items-center gap-1">
+ 납품일
+ <TooltipProvider>
+ <Tooltip>
+ <TooltipTrigger>
+ <Info className="h-4 w-4" />
+ </TooltipTrigger>
+ <TooltipContent>
+ <p>납품 가능한 날짜를 선택해주세요.</p>
+ </TooltipContent>
+ </Tooltip>
+ </TooltipProvider>
+ </div>
+ </TableHead>
+ <TableHead>리드타임(일)</TableHead>
+ <TableHead>
+ <div className="flex items-center gap-1">
+ 대체품
+ <TooltipProvider>
+ <Tooltip>
+ <TooltipTrigger>
+ <Info className="h-4 w-4" />
+ </TooltipTrigger>
+ <TooltipContent>
+ <p>요청된 제품의 대체품을 제안할 경우 선택하세요.</p>
+ <p>대체품을 선택하면 추가 정보를 입력할 수 있습니다.</p>
+ </TooltipContent>
+ </Tooltip>
+ </TooltipProvider>
+ </div>
+ </TableHead>
+ <TableHead className="w-[50px]">상태</TableHead>
+ </TableRow>
+ </TableHeader>
+ <TableBody>
+ {items.length === 0 ? (
+ <TableRow>
+ <TableCell colSpan={12} className="text-center py-10">
+ 견적 항목이 없습니다
+ </TableCell>
+ </TableRow>
+ ) : (
+ items.map((item, index) => (
+ <React.Fragment key={item.id}>
+ <TableRow className={pendingChanges.has(item.id) ? "bg-yellow-50/30" : ""}>
+ <TableCell>
+ {index + 1}
+ </TableCell>
+ <TableCell>
+ {item.materialCode || "-"}
+ </TableCell>
+ <TableCell>
+ <div className="font-medium max-w-xs truncate">
+ {item.materialDescription || "-"}
+ </div>
+ </TableCell>
+ <TableCell>
+ {item.quantity}
+ </TableCell>
+ <TableCell>
+ {item.uom || "-"}
+ </TableCell>
+ <TableCell>
+ {renderInputField(item, index, 'unitPrice')}
+ </TableCell>
+ <TableCell>
+ {formatCurrency(item.totalPrice, currency)}
+ </TableCell>
+ <TableCell>
+ {renderInputField(item, index, 'taxRate')}
+ </TableCell>
+ <TableCell>
+ {renderInputField(item, index, 'deliveryDate')}
+ </TableCell>
+ <TableCell>
+ {renderInputField(item, index, 'leadTimeInDays')}
+ </TableCell>
+ <TableCell>
+ {renderInputField(item, index, 'isAlternative')}
+ </TableCell>
+ <TableCell>
+ {renderSaveStatus(item.id)}
+ </TableCell>
+ </TableRow>
+
+ {/* 대체품으로 선택된 경우 추가 정보 행 표시 */}
+ {item.isAlternative && (
+ <TableRow className={pendingChanges.has(item.id) ? "bg-blue-50/40" : "bg-blue-50/20"}>
+ <TableCell colSpan={1}></TableCell>
+ <TableCell colSpan={10}>
+ {renderAlternativeFields(item, index)}
+ </TableCell>
+ <TableCell colSpan={1}></TableCell>
+ </TableRow>
+ )}
+ </React.Fragment>
+ ))
+ )}
+ </TableBody>
+ </Table>
+ </ScrollArea>
+
+ {isSaving && (
+ <div className="flex items-center justify-center text-sm text-muted-foreground">
+ <Clock className="h-4 w-4 animate-spin mr-2" />
+ 변경 사항을 저장 중입니다...
+ </div>
+ )}
+
+ <div className="bg-muted p-4 rounded-md">
+ <h4 className="text-sm font-medium mb-2">안내 사항</h4>
+ <ul className="text-sm space-y-1 text-muted-foreground">
+ <li className="flex items-start gap-2">
+ <AlertTriangle className="h-4 w-4 mt-0.5 flex-shrink-0" />
+ <span>단가와 납품일은 필수로 입력해야 합니다.</span>
+ </li>
+ <li className="flex items-start gap-2">
+ <ClipboardCheck className="h-4 w-4 mt-0.5 flex-shrink-0" />
+ <span>입력 후 다른 필드로 이동하면 자동으로 저장됩니다. 여러 항목을 변경한 후 '저장' 버튼을 사용할 수도 있습니다.</span>
+ </li>
+ <li className="flex items-start gap-2">
+ <FileText className="h-4 w-4 mt-0.5 flex-shrink-0" />
+ <span><strong>대체품</strong>으로 제안하는 경우 자재명, 대체품 설명을 입력해주세요.</span>
+ </li>
+ </ul>
+ </div>
+ </div>
+ )
+} \ No newline at end of file
diff --git a/lib/procurement-rfqs/vendor-response/table/vendor-quotations-table-columns.tsx b/lib/procurement-rfqs/vendor-response/table/vendor-quotations-table-columns.tsx
new file mode 100644
index 00000000..9eecc72f
--- /dev/null
+++ b/lib/procurement-rfqs/vendor-response/table/vendor-quotations-table-columns.tsx
@@ -0,0 +1,239 @@
+"use client"
+
+import * as React from "react"
+import { type DataTableRowAction } from "@/types/table"
+import { type ColumnDef } from "@tanstack/react-table"
+import { Ellipsis, FileText, Pencil, Edit, Trash2 } 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 {
+ DropdownMenu,
+ DropdownMenuContent,
+ DropdownMenuItem,
+ DropdownMenuSeparator,
+ DropdownMenuShortcut,
+ DropdownMenuTrigger,
+} from "@/components/ui/dropdown-menu"
+import {
+ Tooltip,
+ TooltipContent,
+ TooltipProvider,
+ TooltipTrigger,
+} from "@/components/ui/tooltip"
+import { DataTableColumnHeaderSimple } from "@/components/data-table/data-table-column-simple-header"
+import Link from "next/link"
+import { ProcurementVendorQuotations } from "@/db/schema"
+import { useRouter } from "next/navigation"
+
+// 상태에 따른 배지 컴포넌트
+function StatusBadge({ status }: { status: string }) {
+ switch (status) {
+ case "Draft":
+ return <Badge variant="outline">초안</Badge>
+ case "Submitted":
+ return <Badge variant="default">제출됨</Badge>
+ case "Revised":
+ return <Badge variant="secondary">수정됨</Badge>
+ case "Rejected":
+ return <Badge variant="destructive">반려됨</Badge>
+ case "Accepted":
+ return <Badge variant="default">승인됨</Badge>
+ default:
+ return <Badge>{status}</Badge>
+ }
+}
+
+interface QuotationWithRfqCode extends ProcurementVendorQuotations {
+ rfqCode?: string;
+ rfq?: {
+ rfqCode?: string;
+ dueDate?: Date | string | null;
+
+ } | null;
+}
+
+type NextRouter = ReturnType<typeof useRouter>;
+
+interface GetColumnsProps {
+ setRowAction: React.Dispatch<
+ React.SetStateAction<DataTableRowAction<QuotationWithRfqCode> | null>
+ >
+ router: NextRouter
+}
+
+/**
+ * tanstack table 컬럼 정의
+ */
+export function getColumns({
+ setRowAction,
+ router,
+}: GetColumnsProps): ColumnDef<QuotationWithRfqCode>[] {
+ // ----------------------------------------------------------------
+ // 1) select 컬럼 (체크박스)
+ // ----------------------------------------------------------------
+ const selectColumn: ColumnDef<QuotationWithRfqCode> = {
+ id: "select",
+ header: ({ table }) => (
+ <Checkbox
+ checked={
+ table.getIsAllPageRowsSelected() ||
+ (table.getIsSomePageRowsSelected() && "indeterminate")
+ }
+ onCheckedChange={(value) => table.toggleAllPageRowsSelected(!!value)}
+ aria-label="Select all"
+ className="translate-y-0.5"
+ />
+ ),
+ cell: ({ row }) => (
+ <Checkbox
+ checked={row.getIsSelected()}
+ onCheckedChange={(value) => row.toggleSelected(!!value)}
+ aria-label="Select row"
+ className="translate-y-0.5"
+ />
+ ),
+ size: 40,
+ enableSorting: false,
+ enableHiding: false,
+ }
+
+
+ // ----------------------------------------------------------------
+ // 3) 일반 컬럼들
+ // ----------------------------------------------------------------
+
+ // 견적서 액션 컬럼 (아이콘 버튼으로 변경)
+ const quotationActionColumn: ColumnDef<QuotationWithRfqCode> = {
+ id: "actions",
+ enableHiding: false,
+ cell: ({ row }) => {
+ const id = row.original.id
+ const code = row.getValue("quotationCode") as string
+ const tooltipText = `${code} 작성하기`
+
+ return (
+ <TooltipProvider>
+ <Tooltip>
+ <TooltipTrigger asChild>
+ <Button
+ variant="ghost"
+ size="icon"
+ onClick={() => router.push(`/partners/rfq-all/${id}`)}
+ className="h-8 w-8"
+ >
+ <Edit className="h-4 w-4" />
+ <span className="sr-only">견적서 작성</span>
+ </Button>
+ </TooltipTrigger>
+ <TooltipContent>
+ <p>{tooltipText}</p>
+ </TooltipContent>
+ </Tooltip>
+ </TooltipProvider>
+ )
+ },
+ size: 50, // 아이콘으로 변경했으므로 크기 줄임
+ }
+
+ // RFQ 번호 컬럼
+ const rfqCodeColumn: ColumnDef<QuotationWithRfqCode> = {
+ accessorKey: "quotationCode",
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="RFQ 번호" />
+ ),
+ cell: ({ row }) => row.original.quotationCode || "-",
+ size: 150,
+ }
+
+ // RFQ 버전 컬럼
+ const quotationVersionColumn: ColumnDef<QuotationWithRfqCode> = {
+ accessorKey: "quotationVersion",
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="RFQ 버전" />
+ ),
+ cell: ({ row }) => row.original.quotationVersion || "-",
+ size: 100,
+ }
+
+ const dueDateColumn: ColumnDef<QuotationWithRfqCode> = {
+ accessorKey: "dueDate",
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="RFQ 마감일" />
+ ),
+ cell: ({ row }) => {
+ // 타입 단언 사용
+ const rfq = row.original.rfq as any;
+ const date = rfq?.dueDate as string | null;
+ return date ? formatDateTime(new Date(date)) : "-";
+ },
+ size: 100,
+ }
+
+ // 상태 컬럼
+ const statusColumn: ColumnDef<QuotationWithRfqCode> = {
+ accessorKey: "status",
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="상태" />
+ ),
+ cell: ({ row }) => <StatusBadge status={row.getValue("status") as string} />,
+ size: 100,
+ }
+
+ // 총액 컬럼
+ const totalPriceColumn: ColumnDef<QuotationWithRfqCode> = {
+ accessorKey: "totalPrice",
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="총액" />
+ ),
+ cell: ({ row }) => {
+ const price = parseFloat(row.getValue("totalPrice") as string || "0")
+ const currency = row.original.currency
+
+ return formatCurrency(price, currency)
+ },
+ size: 120,
+ }
+
+ // 제출일 컬럼
+ const submittedAtColumn: ColumnDef<QuotationWithRfqCode> = {
+ accessorKey: "submittedAt",
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="제출일" />
+ ),
+ cell: ({ row }) => {
+ const date = row.getValue("submittedAt") as string | null
+ return date ? formatDate(new Date(date)) : "-"
+ },
+ size: 120,
+ }
+
+ // 유효기간 컬럼
+ const validUntilColumn: ColumnDef<QuotationWithRfqCode> = {
+ accessorKey: "validUntil",
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="유효기간" />
+ ),
+ cell: ({ row }) => {
+ const date = row.getValue("validUntil") as string | null
+ return date ? formatDate(new Date(date)) : "-"
+ },
+ size: 120,
+ }
+
+ // ----------------------------------------------------------------
+ // 4) 최종 컬럼 배열
+ // ----------------------------------------------------------------
+ return [
+ selectColumn,
+ rfqCodeColumn,
+ quotationVersionColumn,
+ dueDateColumn,
+ statusColumn,
+ totalPriceColumn,
+ submittedAtColumn,
+ validUntilColumn,
+ quotationActionColumn // 이름을 변경하고 마지막에 배치
+ ]
+} \ No newline at end of file
diff --git a/lib/procurement-rfqs/vendor-response/table/vendor-quotations-table.tsx b/lib/procurement-rfqs/vendor-response/table/vendor-quotations-table.tsx
new file mode 100644
index 00000000..92bda337
--- /dev/null
+++ b/lib/procurement-rfqs/vendor-response/table/vendor-quotations-table.tsx
@@ -0,0 +1,145 @@
+// lib/vendor-quotations/vendor-quotations-table.tsx
+"use client"
+
+import * as React from "react"
+import { type DataTableAdvancedFilterField, type DataTableFilterField, type DataTableRowAction } 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 { Button } from "@/components/ui/button"
+import { ProcurementVendorQuotations } from "@/db/schema"
+import { useRouter } from "next/navigation"
+import { getColumns } from "./vendor-quotations-table-columns"
+
+interface QuotationWithRfqCode extends ProcurementVendorQuotations {
+ rfqCode?: string;
+ rfq?: {
+ rfqCode?: string;
+ } | null;
+}
+
+interface VendorQuotationsTableProps {
+ promises: Promise<[{ data: ProcurementVendorQuotations[], pageCount: number }]>;
+}
+
+export function VendorQuotationsTable({ promises }: VendorQuotationsTableProps) {
+ const [{ data, pageCount }] = React.use(promises);
+ const router = useRouter();
+
+ console.log(data ,"data")
+
+ // 선택된 행 액션 상태
+ const [rowAction, setRowAction] = React.useState<DataTableRowAction<QuotationWithRfqCode> | null>(null);
+
+ // 테이블 컬럼 정의
+ const columns = React.useMemo(() => getColumns({
+ setRowAction,
+ router,
+ }), [setRowAction, router]);
+
+ // 상태별 견적서 수 계산
+ const statusCounts = React.useMemo(() => {
+ return {
+ Draft: data.filter(q => q.status === "Draft").length,
+ Submitted: data.filter(q => q.status === "Submitted").length,
+ Revised: data.filter(q => q.status === "Revised").length,
+ Rejected: data.filter(q => q.status === "Rejected").length,
+ Accepted: data.filter(q => q.status === "Accepted").length,
+ };
+ }, [data]);
+
+ // 필터 필드
+ const filterFields: DataTableFilterField<QuotationWithRfqCode>[] = [
+ {
+ id: "status",
+ label: "상태",
+ options: [
+ { label: "초안", value: "Draft", count: statusCounts.Draft },
+ { label: "제출됨", value: "Submitted", count: statusCounts.Submitted },
+ { label: "수정됨", value: "Revised", count: statusCounts.Revised },
+ { label: "반려됨", value: "Rejected", count: statusCounts.Rejected },
+ { label: "승인됨", value: "Accepted", count: statusCounts.Accepted },
+ ]
+ },
+ {
+ id: "quotationCode",
+ label: "견적서 번호",
+ placeholder: "견적서 번호 검색...",
+ },
+ {
+ id: "rfqCode",
+ label: "RFQ 번호",
+ placeholder: "RFQ 번호 검색...",
+ }
+ ];
+
+ // 고급 필터 필드
+ const advancedFilterFields: DataTableAdvancedFilterField<QuotationWithRfqCode>[] = [
+ {
+ id: "quotationCode",
+ label: "견적서 번호",
+ type: "text",
+ },
+ {
+ id: "rfqCode",
+ label: "RFQ 번호",
+ type: "text",
+ },
+ {
+ id: "status",
+ label: "상태",
+ type: "multi-select",
+ options: [
+ { label: "초안", value: "Draft" },
+ { label: "제출됨", value: "Submitted" },
+ { label: "수정됨", value: "Revised" },
+ { label: "반려됨", value: "Rejected" },
+ { label: "승인됨", value: "Accepted" },
+ ],
+ },
+ {
+ id: "validUntil",
+ label: "유효기간",
+ type: "date",
+ },
+ {
+ id: "submittedAt",
+ label: "제출일",
+ type: "date",
+ },
+ ];
+
+ // useDataTable 훅 사용
+ const { table } = useDataTable({
+ data,
+ columns,
+ pageCount,
+ filterFields,
+ enablePinning: true,
+ enableAdvancedFilter: true,
+ initialState: {
+ sorting: [{ id: "updatedAt", desc: true }],
+ columnPinning: { right: ["actions"] },
+ },
+ getRowId: (originalRow) => String(originalRow.id),
+ shallow: false,
+ clearOnDefault: true,
+ });
+
+ return (
+ <div style={{ maxWidth: '100vw' }}>
+ <DataTable
+ table={table}
+ >
+ <DataTableAdvancedToolbar
+ table={table}
+ filterFields={advancedFilterFields}
+ shallow={false}
+ >
+ </DataTableAdvancedToolbar>
+ </DataTable>
+
+
+ </div>
+ );
+} \ No newline at end of file