From 14f61e24947fb92dd71ec0a7196a6e815f8e66da Mon Sep 17 00:00:00 2001 From: dujinkim Date: Mon, 21 Jul 2025 07:54:26 +0000 Subject: (최겸)기술영업 RFQ 담당자 초대, 요구사항 반영 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../vendor-response/detail/communication-tab.tsx | 416 ++++---- .../vendor-response/detail/project-info-tab.tsx | 296 +++--- .../detail/quotation-response-tab.tsx | 1043 ++++++++++---------- .../vendor-response/detail/quotation-tabs.tsx | 166 ++-- 4 files changed, 961 insertions(+), 960 deletions(-) (limited to 'lib/techsales-rfq/vendor-response/detail') diff --git a/lib/techsales-rfq/vendor-response/detail/communication-tab.tsx b/lib/techsales-rfq/vendor-response/detail/communication-tab.tsx index 3f2a5280..5bed179e 100644 --- a/lib/techsales-rfq/vendor-response/detail/communication-tab.tsx +++ b/lib/techsales-rfq/vendor-response/detail/communication-tab.tsx @@ -1,209 +1,209 @@ -"use client" - -import * as React from "react" -import { useState, useEffect } from "react" -import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card" -import { Button } from "@/components/ui/button" -import { Badge } from "@/components/ui/badge" -import { ScrollArea } from "@/components/ui/scroll-area" -import { Skeleton } from "@/components/ui/skeleton" -import { MessageSquare, Paperclip } from "lucide-react" -import { fetchTechSalesVendorCommentsClient, TechSalesComment } from "../buyer-communication-drawer" -import { BuyerCommunicationDrawer } from "../buyer-communication-drawer" - -interface CommunicationTabProps { - quotation: { - id: number - rfqId: number - vendorId: number - quotationCode: string | null - rfq: { - id: number - rfqCode: string | null - createdByUser?: { - id: number - name: string | null - email: string | null - } | null - } | null - vendor: { - vendorName: string - } | null - } -} - -export function CommunicationTab({ quotation }: CommunicationTabProps) { - const [comments, setComments] = useState([]); - const [unreadCount, setUnreadCount] = useState(0); - const [loadingComments, setLoadingComments] = useState(false); - const [communicationDrawerOpen, setCommunicationDrawerOpen] = useState(false); - - // 컴포넌트 마운트 시 메시지 미리 로드 - useEffect(() => { - if (quotation) { - loadCommunicationData(); - } - }, [quotation]); - - // 메시지 데이터 로드 함수 - const loadCommunicationData = async () => { - try { - setLoadingComments(true); - const commentsData = await fetchTechSalesVendorCommentsClient(quotation.rfqId, quotation.vendorId); - setComments(commentsData); - - // 읽지 않은 메시지 수 계산 (구매자가 보낸 메시지 중 읽지 않은 것) - const unread = commentsData.filter( - comment => !comment.isVendorComment && !comment.isRead - ).length; - setUnreadCount(unread); - } catch (error) { - console.error("메시지 데이터 로드 오류:", error); - } finally { - setLoadingComments(false); - } - }; - - // 커뮤니케이션 드로어가 닫힐 때 데이터 새로고침 - const handleCommunicationDrawerChange = (open: boolean) => { - setCommunicationDrawerOpen(open); - if (!open) { - loadCommunicationData(); // 드로어가 닫힐 때 데이터 새로고침 - } - }; - - return ( -
- {/* 헤더 */} - - -
- - - 커뮤니케이션 - {unreadCount > 0 && ( - - 새 메시지 {unreadCount} - - )} - - - RFQ {quotation.rfq?.rfqCode || "미할당"}에 대한 구매담당자와의 커뮤니케이션 - -
- -
- -
- 구매담당자: {quotation.rfq?.createdByUser?.name || "N/A"} - - 벤더: {quotation.vendor?.vendorName} -
-
-
- - {/* 메시지 미리보기 */} - - - 메시지 ({comments.length}) - - - {loadingComments ? ( -
-
- - -
-
- ) : comments.length === 0 ? ( -
-
-
- -
-

아직 메시지가 없습니다

-

- 견적서에 대한 질문이나 의견이 있으신가요? 구매자와 메시지를 주고받으세요. -

- -
-
- ) : ( -
- {/* 최근 메시지 3개 미리보기 */} -
-

최근 메시지

- - {comments.slice(-3).map(comment => ( -
-
- - {comment.isVendorComment - ? '나' - : comment.userName || '구매 담당자'} - - - {new Date(comment.createdAt).toLocaleDateString()} - -
-

{comment.content}

- {comment.attachments.length > 0 && ( -
- - 첨부파일 {comment.attachments.length}개 -
- )} -
- ))} -
-
- -
- -
-
- )} -
-
- - {/* 커뮤니케이션 드로어 */} - -
- ) +"use client" + +import * as React from "react" +import { useState, useEffect } from "react" +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card" +import { Button } from "@/components/ui/button" +import { Badge } from "@/components/ui/badge" +import { ScrollArea } from "@/components/ui/scroll-area" +import { Skeleton } from "@/components/ui/skeleton" +import { MessageSquare, Paperclip } from "lucide-react" +import { fetchTechSalesVendorCommentsClient, TechSalesComment } from "../buyer-communication-drawer" +import { BuyerCommunicationDrawer } from "../buyer-communication-drawer" + +interface CommunicationTabProps { + quotation: { + id: number + rfqId: number + vendorId: number + quotationCode: string | null + rfq: { + id: number + rfqCode: string | null + createdByUser?: { + id: number + name: string | null + email: string | null + } | null + } | null + vendor: { + vendorName: string + } | null + } +} + +export function CommunicationTab({ quotation }: CommunicationTabProps) { + const [comments, setComments] = useState([]); + const [unreadCount, setUnreadCount] = useState(0); + const [loadingComments, setLoadingComments] = useState(false); + const [communicationDrawerOpen, setCommunicationDrawerOpen] = useState(false); + + // 컴포넌트 마운트 시 메시지 미리 로드 + useEffect(() => { + if (quotation) { + loadCommunicationData(); + } + }, [quotation]); + + // 메시지 데이터 로드 함수 + const loadCommunicationData = async () => { + try { + setLoadingComments(true); + const commentsData = await fetchTechSalesVendorCommentsClient(quotation.rfqId, quotation.vendorId); + setComments(commentsData); + + // 읽지 않은 메시지 수 계산 (구매자가 보낸 메시지 중 읽지 않은 것) + const unread = commentsData.filter( + comment => !comment.isVendorComment && !comment.isRead + ).length; + setUnreadCount(unread); + } catch (error) { + console.error("메시지 데이터 로드 오류:", error); + } finally { + setLoadingComments(false); + } + }; + + // 커뮤니케이션 드로어가 닫힐 때 데이터 새로고침 + const handleCommunicationDrawerChange = (open: boolean) => { + setCommunicationDrawerOpen(open); + if (!open) { + loadCommunicationData(); // 드로어가 닫힐 때 데이터 새로고침 + } + }; + + return ( +
+ {/* 헤더 */} + + +
+ + + 커뮤니케이션 + {unreadCount > 0 && ( + + 새 메시지 {unreadCount} + + )} + + + RFQ {quotation.rfq?.rfqCode || "미할당"}에 대한 구매담당자와의 커뮤니케이션 + +
+ +
+ +
+ 구매담당자: {quotation.rfq?.createdByUser?.name || "N/A"} + + 벤더: {quotation.vendor?.vendorName} +
+
+
+ + {/* 메시지 미리보기 */} + + + 메시지 ({comments.length}) + + + {loadingComments ? ( +
+
+ + +
+
+ ) : comments.length === 0 ? ( +
+
+
+ +
+

아직 메시지가 없습니다

+

+ 견적서에 대한 질문이나 의견이 있으신가요? 구매자와 메시지를 주고받으세요. +

+ +
+
+ ) : ( +
+ {/* 최근 메시지 3개 미리보기 */} +
+

최근 메시지

+ + {comments.slice(-3).map(comment => ( +
+
+ + {comment.isVendorComment + ? '나' + : comment.userName || '구매 담당자'} + + + {new Date(comment.createdAt).toLocaleDateString()} + +
+

{comment.content}

+ {comment.attachments.length > 0 && ( +
+ + 첨부파일 {comment.attachments.length}개 +
+ )} +
+ ))} +
+
+ +
+ +
+
+ )} +
+
+ + {/* 커뮤니케이션 드로어 */} + +
+ ) } \ No newline at end of file diff --git a/lib/techsales-rfq/vendor-response/detail/project-info-tab.tsx b/lib/techsales-rfq/vendor-response/detail/project-info-tab.tsx index a8f44474..771db896 100644 --- a/lib/techsales-rfq/vendor-response/detail/project-info-tab.tsx +++ b/lib/techsales-rfq/vendor-response/detail/project-info-tab.tsx @@ -1,149 +1,149 @@ -"use client" - -import * as React from "react" -import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card" -import { Badge } from "@/components/ui/badge" -import { ScrollArea } from "@/components/ui/scroll-area" -import { formatDate } from "@/lib/utils" - -interface ProjectInfoTabProps { - quotation: { - id: number - rfq: { - id: number - rfqCode: string | null - materialCode: string | null - dueDate: Date | null - status: string | null - remark: string | null - biddingProject?: { - id: number - pspid: string | null - projNm: string | null - sector: string | null - projMsrm: string | null - ptypeNm: string | null - } | null - createdByUser?: { - id: number - name: string | null - email: string | null - } | null - } | null - vendor: { - id: number - vendorName: string - vendorCode: string | null - } | null - } -} - -export function ProjectInfoTab({ quotation }: ProjectInfoTabProps) { - const rfq = quotation.rfq - - console.log("rfq: ", rfq) - - if (!rfq) { - return ( -
-
-

RFQ 정보를 찾을 수 없습니다

-

- 연결된 RFQ 정보가 없습니다. -

-
-
- ) - } - - return ( - -
- {/* RFQ 기본 정보 */} - - - - RFQ 기본 정보 - {rfq.rfqCode || "미할당"} - - - 요청서 기본 정보 및 자재 정보 - - - -
-
-
RFQ 번호
-
{rfq.rfqCode || "미할당"}
-
-
-
자재 그룹
-
{rfq.materialCode || "N/A"}
-
-
-
마감일
-
- {rfq.dueDate ? formatDate(rfq.dueDate) : "N/A"} -
-
-
-
RFQ 상태
-
{rfq.status || "N/A"}
-
-
-
담당자
-
{rfq.createdByUser?.name || "N/A"}
-
-
- {rfq.remark && ( -
-
비고
-
{rfq.remark}
-
- )} -
-
- - {/* 프로젝트 기본 정보 */} - {rfq.biddingProject && ( - - - - 프로젝트 기본 정보 - {rfq.biddingProject.pspid || "N/A"} - - - 연결된 프로젝트의 기본 정보 - - - -
-
-
프로젝트 ID
-
{rfq.biddingProject.pspid || "N/A"}
-
-
-
프로젝트명
-
{rfq.biddingProject.projNm || "N/A"}
-
-
-
프로젝트 섹터
-
{rfq.biddingProject.sector || "N/A"}
-
-
-
프로젝트 규모
-
{rfq.biddingProject.projMsrm || "N/A"}
-
-
-
프로젝트 타입
-
{rfq.biddingProject.ptypeNm || "N/A"}
-
-
-
-
- )} - -
-
- ) +"use client" + +import * as React from "react" +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card" +import { Badge } from "@/components/ui/badge" +import { ScrollArea } from "@/components/ui/scroll-area" +import { formatDate } from "@/lib/utils" + +interface ProjectInfoTabProps { + quotation: { + id: number + rfq: { + id: number + rfqCode: string | null + materialCode: string | null + dueDate: Date | null + status: string | null + remark: string | null + biddingProject?: { + id: number + pspid: string | null + projNm: string | null + sector: string | null + projMsrm: string | null + ptypeNm: string | null + } | null + createdByUser?: { + id: number + name: string | null + email: string | null + } | null + } | null + vendor: { + id: number + vendorName: string + vendorCode: string | null + } | null + } +} + +export function ProjectInfoTab({ quotation }: ProjectInfoTabProps) { + const rfq = quotation.rfq + + console.log("rfq: ", rfq) + + if (!rfq) { + return ( +
+
+

RFQ 정보를 찾을 수 없습니다

+

+ 연결된 RFQ 정보가 없습니다. +

+
+
+ ) + } + + return ( + +
+ {/* RFQ 기본 정보 */} + + + + RFQ 기본 정보 + {rfq.rfqCode || "미할당"} + + + 요청서 기본 정보 및 자재 정보 + + + +
+
+
RFQ 번호
+
{rfq.rfqCode || "미할당"}
+
+
+
자재 그룹
+
{rfq.materialCode || "N/A"}
+
+
+
마감일
+
+ {rfq.dueDate ? formatDate(rfq.dueDate) : "N/A"} +
+
+
+
RFQ 상태
+
{rfq.status || "N/A"}
+
+
+
담당자
+
{rfq.createdByUser?.name || "N/A"}
+
+
+ {rfq.remark && ( +
+
비고
+
{rfq.remark}
+
+ )} +
+
+ + {/* 프로젝트 기본 정보 */} + {rfq.biddingProject && ( + + + + 프로젝트 기본 정보 + {rfq.biddingProject.pspid || "N/A"} + + + 연결된 프로젝트의 기본 정보 + + + +
+
+
프로젝트 ID
+
{rfq.biddingProject.pspid || "N/A"}
+
+
+
프로젝트명
+
{rfq.biddingProject.projNm || "N/A"}
+
+
+
프로젝트 섹터
+
{rfq.biddingProject.sector || "N/A"}
+
+
+
프로젝트 규모
+
{rfq.biddingProject.projMsrm || "N/A"}
+
+
+
프로젝트 타입
+
{rfq.biddingProject.ptypeNm || "N/A"}
+
+
+
+
+ )} + +
+
+ ) } \ No newline at end of file diff --git a/lib/techsales-rfq/vendor-response/detail/quotation-response-tab.tsx b/lib/techsales-rfq/vendor-response/detail/quotation-response-tab.tsx index 0425ccc9..0a56b702 100644 --- a/lib/techsales-rfq/vendor-response/detail/quotation-response-tab.tsx +++ b/lib/techsales-rfq/vendor-response/detail/quotation-response-tab.tsx @@ -1,522 +1,523 @@ -"use client" - -import * as React from "react" -import { useState, useEffect } from "react" -import { useRouter } from "next/navigation" -import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card" -import { Button } from "@/components/ui/button" -import { Input } from "@/components/ui/input" -import { Label } from "@/components/ui/label" -import { Textarea } from "@/components/ui/textarea" -import { Badge } from "@/components/ui/badge" -import { ScrollArea } from "@/components/ui/scroll-area" -import { CalendarIcon, Send, AlertCircle, Upload, X, FileText, Download } from "lucide-react" -import { Calendar } from "@/components/ui/calendar" -import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover" -import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select" -import { Alert, AlertDescription } from "@/components/ui/alert" -import { formatDate, cn } from "@/lib/utils" -import { toast } from "sonner" -import { useSession } from "next-auth/react" - -interface QuotationResponseTabProps { - quotation: { - id: number - status: string - totalPrice: string | null - currency: string | null - validUntil: Date | null - remark: string | null - quotationAttachments?: Array<{ - id: number - fileName: string - fileSize: number - filePath: string - description?: string | null - }> - rfq: { - id: number - rfqCode: string | null - materialCode: string | null - dueDate: Date | null - status: string | null - item?: { - itemName: string | null - } | null - } | null - vendor: { - vendorName: string - } | null - } -} - -const CURRENCIES = [ - { value: "KRW", label: "KRW (원)" }, - { value: "USD", label: "USD (달러)" }, - { value: "EUR", label: "EUR (유로)" }, - { value: "JPY", label: "JPY (엔)" }, - { value: "CNY", label: "CNY (위안)" }, -] - -export function QuotationResponseTab({ quotation }: QuotationResponseTabProps) { - const [totalPrice, setTotalPrice] = useState(quotation.totalPrice?.toString() || "") - const [currency, setCurrency] = useState(quotation.currency || "KRW") - const [validUntil, setValidUntil] = useState( - quotation.validUntil ? new Date(quotation.validUntil) : undefined - ) - const [remark, setRemark] = useState(quotation.remark || "") - const [isLoading, setIsLoading] = useState(false) - const [attachments, setAttachments] = useState>([]) - const [isUploadingFiles, setIsUploadingFiles] = useState(false) - const router = useRouter() - const session = useSession() - - // // 초기 첨부파일 데이터 로드 - // useEffect(() => { - // if (quotation.quotationAttachments) { - // setAttachments(quotation.quotationAttachments.map(att => ({ - // id: att.id, - // fileName: att.fileName, - // fileSize: att.fileSize, - // filePath: att.filePath, - // isNew: false - // }))) - // } - // }, [quotation.quotationAttachments]) - - const rfq = quotation.rfq - const isDueDatePassed = rfq?.dueDate ? new Date(rfq.dueDate) < new Date() : false - const canSubmit = !["Accepted", "Rejected"].includes(quotation.status) && !isDueDatePassed - const canEdit = !["Accepted", "Rejected"].includes(quotation.status) && !isDueDatePassed - - // 파일 업로드 핸들러 - const handleFileSelect = (event: React.ChangeEvent) => { - const files = event.target.files - if (!files) return - - Array.from(files).forEach(file => { - setAttachments(prev => [ - ...prev, - { - fileName: file.name, - fileSize: file.size, - filePath: '', - isNew: true, - file - } - ]) - }) - } - - // 첨부파일 제거 - const removeAttachment = (index: number) => { - setAttachments(prev => prev.filter((_, i) => i !== index)) - } - - // 파일 업로드 함수 - const uploadFiles = async () => { - const newFiles = attachments.filter(att => att.isNew && att.file) - if (newFiles.length === 0) return [] - - setIsUploadingFiles(true) - const uploadedFiles = [] - - try { - for (const attachment of newFiles) { - const formData = new FormData() - formData.append('file', attachment.file!) - - const response = await fetch('/api/upload', { - method: 'POST', - body: formData - }) - - if (!response.ok) throw new Error('파일 업로드 실패') - - const result = await response.json() - uploadedFiles.push({ - fileName: result.fileName, - filePath: result.url, - fileSize: attachment.fileSize - }) - } - return uploadedFiles - } catch (error) { - console.error('파일 업로드 오류:', error) - toast.error('파일 업로드 중 오류가 발생했습니다.') - return [] - } finally { - setIsUploadingFiles(false) - } - } - - const handleSubmit = async () => { - if (!totalPrice || !currency || !validUntil) { - toast.error("모든 필수 항목을 입력해주세요.") - return - } - - setIsLoading(true) - try { - // 파일 업로드 먼저 처리 - const uploadedFiles = await uploadFiles() - - const { submitTechSalesVendorQuotation } = await import("@/lib/techsales-rfq/service") - - const result = await submitTechSalesVendorQuotation({ - id: quotation.id, - currency, - totalPrice, - validUntil: validUntil!, - remark, - attachments: uploadedFiles, - updatedBy: parseInt(session.data?.user.id || "0") - }) - - if (result.error) { - toast.error(result.error) - } else { - toast.success("견적서가 제출되었습니다.") - // // 페이지 새로고침 대신 router.refresh() 사용 - // router.refresh() - // 페이지 새로고침 - window.location.reload() - } - } catch { - toast.error("제출 중 오류가 발생했습니다.") - } finally { - setIsLoading(false) - } - } - - const getStatusBadgeVariant = (status: string) => { - switch (status) { - case "Draft": - return "secondary" - case "Submitted": - return "default" - case "Revised": - return "outline" - case "Rejected": - return "destructive" - case "Accepted": - return "success" - default: - return "secondary" - } - } - - const getStatusLabel = (status: string) => { - switch (status) { - case "Draft": - return "초안" - case "Submitted": - return "제출됨" - case "Revised": - return "수정됨" - case "Rejected": - return "반려됨" - case "Accepted": - return "승인됨" - default: - return status - } - } - - return ( - -
- {/* 견적서 상태 정보 */} - - - - 견적서 상태 - - {getStatusLabel(quotation.status)} - - - - 현재 견적서 상태 및 마감일 정보 - - - -
-
-
견적서 상태
-
{getStatusLabel(quotation.status)}
-
-
-
RFQ 마감일
-
- {rfq?.dueDate ? formatDate(rfq.dueDate) : "N/A"} -
-
-
-
남은 시간
-
- {isDueDatePassed ? ( - 마감됨 - ) : rfq?.dueDate ? ( - - {Math.ceil((new Date(rfq.dueDate).getTime() - new Date().getTime()) / (1000 * 60 * 60 * 24))}일 - - ) : ( - "N/A" - )} -
-
-
- - {isDueDatePassed && ( - - - - RFQ 마감일이 지났습니다. 견적서를 수정하거나 제출할 수 없습니다. - - - )} - - {!canEdit && !isDueDatePassed && ( - - - - 현재 상태에서는 견적서를 수정할 수 없습니다. - - - )} -
-
- - {/* 견적 응답 폼 */} - - - 견적 응답 - - 총 가격, 통화, 유효기간을 입력해주세요. - - - - {/* 총 가격 */} -
- - setTotalPrice(e.target.value)} - disabled={!canEdit} - className="text-right" - /> -
- - {/* 통화 */} -
- - -
- - {/* 유효기간 */} -
- - - - - - - date < new Date()} - initialFocus - /> - - -
- - {/* 비고 */} -
- -