diff options
| author | dujinkim <dujin.kim@dtsolution.co.kr> | 2025-07-21 07:54:26 +0000 |
|---|---|---|
| committer | dujinkim <dujin.kim@dtsolution.co.kr> | 2025-07-21 07:54:26 +0000 |
| commit | 14f61e24947fb92dd71ec0a7196a6e815f8e66da (patch) | |
| tree | 317c501d64662d05914330628f867467fba78132 /lib/techsales-rfq/vendor-response/detail | |
| parent | 194bd4bd7e6144d5c09c5e3f5476d254234dce72 (diff) | |
(최겸)기술영업 RFQ 담당자 초대, 요구사항 반영
Diffstat (limited to 'lib/techsales-rfq/vendor-response/detail')
4 files changed, 961 insertions, 960 deletions
diff --git a/lib/techsales-rfq/vendor-response/detail/communication-tab.tsx b/lib/techsales-rfq/vendor-response/detail/communication-tab.tsx index 3f2a5280..5bed179e 100644 --- a/lib/techsales-rfq/vendor-response/detail/communication-tab.tsx +++ b/lib/techsales-rfq/vendor-response/detail/communication-tab.tsx @@ -1,209 +1,209 @@ -"use client" - -import * as React from "react" -import { useState, useEffect } from "react" -import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card" -import { Button } from "@/components/ui/button" -import { Badge } from "@/components/ui/badge" -import { ScrollArea } from "@/components/ui/scroll-area" -import { Skeleton } from "@/components/ui/skeleton" -import { MessageSquare, Paperclip } from "lucide-react" -import { fetchTechSalesVendorCommentsClient, TechSalesComment } from "../buyer-communication-drawer" -import { BuyerCommunicationDrawer } from "../buyer-communication-drawer" - -interface CommunicationTabProps { - quotation: { - id: number - rfqId: number - vendorId: number - quotationCode: string | null - rfq: { - id: number - rfqCode: string | null - createdByUser?: { - id: number - name: string | null - email: string | null - } | null - } | null - vendor: { - vendorName: string - } | null - } -} - -export function CommunicationTab({ quotation }: CommunicationTabProps) { - const [comments, setComments] = useState<TechSalesComment[]>([]); - const [unreadCount, setUnreadCount] = useState(0); - const [loadingComments, setLoadingComments] = useState(false); - const [communicationDrawerOpen, setCommunicationDrawerOpen] = useState(false); - - // 컴포넌트 마운트 시 메시지 미리 로드 - useEffect(() => { - if (quotation) { - loadCommunicationData(); - } - }, [quotation]); - - // 메시지 데이터 로드 함수 - const loadCommunicationData = async () => { - try { - setLoadingComments(true); - const commentsData = await fetchTechSalesVendorCommentsClient(quotation.rfqId, quotation.vendorId); - setComments(commentsData); - - // 읽지 않은 메시지 수 계산 (구매자가 보낸 메시지 중 읽지 않은 것) - const unread = commentsData.filter( - comment => !comment.isVendorComment && !comment.isRead - ).length; - setUnreadCount(unread); - } catch (error) { - console.error("메시지 데이터 로드 오류:", error); - } finally { - setLoadingComments(false); - } - }; - - // 커뮤니케이션 드로어가 닫힐 때 데이터 새로고침 - const handleCommunicationDrawerChange = (open: boolean) => { - setCommunicationDrawerOpen(open); - if (!open) { - loadCommunicationData(); // 드로어가 닫힐 때 데이터 새로고침 - } - }; - - return ( - <div className="h-full flex flex-col"> - {/* 헤더 */} - <Card className="mb-4"> - <CardHeader className="flex flex-row items-center justify-between"> - <div> - <CardTitle className="flex items-center gap-2"> - <MessageSquare className="h-5 w-5" /> - 커뮤니케이션 - {unreadCount > 0 && ( - <Badge variant="destructive" className="ml-2"> - 새 메시지 {unreadCount} - </Badge> - )} - </CardTitle> - <CardDescription> - RFQ {quotation.rfq?.rfqCode || "미할당"}에 대한 구매담당자와의 커뮤니케이션 - </CardDescription> - </div> - <Button - onClick={() => setCommunicationDrawerOpen(true)} - variant="outline" - size="sm" - > - <MessageSquare className="h-4 w-4 mr-2" /> - {unreadCount > 0 ? "새 메시지 확인" : "메시지 보내기"} - </Button> - </CardHeader> - <CardContent> - <div className="flex items-center gap-4 text-sm text-muted-foreground"> - <span>구매담당자: {quotation.rfq?.createdByUser?.name || "N/A"}</span> - <span>•</span> - <span>벤더: {quotation.vendor?.vendorName}</span> - </div> - </CardContent> - </Card> - - {/* 메시지 미리보기 */} - <Card className="flex-1 flex flex-col min-h-0"> - <CardHeader> - <CardTitle className="text-lg">메시지 ({comments.length})</CardTitle> - </CardHeader> - <CardContent> - {loadingComments ? ( - <div className="flex items-center justify-center p-8"> - <div className="text-center"> - <Skeleton className="h-4 w-32 mx-auto mb-2" /> - <Skeleton className="h-4 w-48 mx-auto" /> - </div> - </div> - ) : comments.length === 0 ? ( - <div className="min-h-[200px] flex flex-col items-center justify-center text-center p-8"> - <div className="max-w-md"> - <div className="mx-auto bg-primary/10 rounded-full w-12 h-12 flex items-center justify-center mb-4"> - <MessageSquare className="h-6 w-6 text-primary" /> - </div> - <h3 className="text-lg font-medium mb-2">아직 메시지가 없습니다</h3> - <p className="text-muted-foreground mb-4"> - 견적서에 대한 질문이나 의견이 있으신가요? 구매자와 메시지를 주고받으세요. - </p> - <Button - onClick={() => setCommunicationDrawerOpen(true)} - className="mx-auto" - > - 메시지 보내기 - </Button> - </div> - </div> - ) : ( - <div className="space-y-4"> - {/* 최근 메시지 3개 미리보기 */} - <div className="space-y-2"> - <h3 className="text-sm font-medium">최근 메시지</h3> - <ScrollArea className="h-[250px] rounded-md border p-4"> - {comments.slice(-3).map(comment => ( - <div - key={comment.id} - className={`p-3 mb-3 rounded-lg ${!comment.isVendorComment && !comment.isRead - ? 'bg-primary/10 border-l-4 border-primary' - : 'bg-muted/50' - }`} - > - <div className="flex justify-between items-center mb-1"> - <span className="text-sm font-medium"> - {comment.isVendorComment - ? '나' - : comment.userName || '구매 담당자'} - </span> - <span className="text-xs text-muted-foreground"> - {new Date(comment.createdAt).toLocaleDateString()} - </span> - </div> - <p className="text-sm line-clamp-2">{comment.content}</p> - {comment.attachments.length > 0 && ( - <div className="mt-1 text-xs text-muted-foreground"> - <Paperclip className="h-3 w-3 inline mr-1" /> - 첨부파일 {comment.attachments.length}개 - </div> - )} - </div> - ))} - </ScrollArea> - </div> - - <div className="flex justify-center"> - <Button - onClick={() => setCommunicationDrawerOpen(true)} - className="w-full" - > - 전체 메시지 보기 ({comments.length}개) - </Button> - </div> - </div> - )} - </CardContent> - </Card> - - {/* 커뮤니케이션 드로어 */} - <BuyerCommunicationDrawer - open={communicationDrawerOpen} - onOpenChange={handleCommunicationDrawerChange} - quotation={{ - id: quotation.id, - rfqId: quotation.rfqId, - vendorId: quotation.vendorId, - quotationCode: quotation.quotationCode, - rfq: quotation.rfq ? { - rfqCode: quotation.rfq.rfqCode - } : undefined - }} - onSuccess={loadCommunicationData} - /> - </div> - ) +"use client"
+
+import * as React from "react"
+import { useState, useEffect } from "react"
+import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
+import { Button } from "@/components/ui/button"
+import { Badge } from "@/components/ui/badge"
+import { ScrollArea } from "@/components/ui/scroll-area"
+import { Skeleton } from "@/components/ui/skeleton"
+import { MessageSquare, Paperclip } from "lucide-react"
+import { fetchTechSalesVendorCommentsClient, TechSalesComment } from "../buyer-communication-drawer"
+import { BuyerCommunicationDrawer } from "../buyer-communication-drawer"
+
+interface CommunicationTabProps {
+ quotation: {
+ id: number
+ rfqId: number
+ vendorId: number
+ quotationCode: string | null
+ rfq: {
+ id: number
+ rfqCode: string | null
+ createdByUser?: {
+ id: number
+ name: string | null
+ email: string | null
+ } | null
+ } | null
+ vendor: {
+ vendorName: string
+ } | null
+ }
+}
+
+export function CommunicationTab({ quotation }: CommunicationTabProps) {
+ const [comments, setComments] = useState<TechSalesComment[]>([]);
+ const [unreadCount, setUnreadCount] = useState(0);
+ const [loadingComments, setLoadingComments] = useState(false);
+ const [communicationDrawerOpen, setCommunicationDrawerOpen] = useState(false);
+
+ // 컴포넌트 마운트 시 메시지 미리 로드
+ useEffect(() => {
+ if (quotation) {
+ loadCommunicationData();
+ }
+ }, [quotation]);
+
+ // 메시지 데이터 로드 함수
+ const loadCommunicationData = async () => {
+ try {
+ setLoadingComments(true);
+ const commentsData = await fetchTechSalesVendorCommentsClient(quotation.rfqId, quotation.vendorId);
+ setComments(commentsData);
+
+ // 읽지 않은 메시지 수 계산 (구매자가 보낸 메시지 중 읽지 않은 것)
+ const unread = commentsData.filter(
+ comment => !comment.isVendorComment && !comment.isRead
+ ).length;
+ setUnreadCount(unread);
+ } catch (error) {
+ console.error("메시지 데이터 로드 오류:", error);
+ } finally {
+ setLoadingComments(false);
+ }
+ };
+
+ // 커뮤니케이션 드로어가 닫힐 때 데이터 새로고침
+ const handleCommunicationDrawerChange = (open: boolean) => {
+ setCommunicationDrawerOpen(open);
+ if (!open) {
+ loadCommunicationData(); // 드로어가 닫힐 때 데이터 새로고침
+ }
+ };
+
+ return (
+ <div className="h-full flex flex-col">
+ {/* 헤더 */}
+ <Card className="mb-4">
+ <CardHeader className="flex flex-row items-center justify-between">
+ <div>
+ <CardTitle className="flex items-center gap-2">
+ <MessageSquare className="h-5 w-5" />
+ 커뮤니케이션
+ {unreadCount > 0 && (
+ <Badge variant="destructive" className="ml-2">
+ 새 메시지 {unreadCount}
+ </Badge>
+ )}
+ </CardTitle>
+ <CardDescription>
+ RFQ {quotation.rfq?.rfqCode || "미할당"}에 대한 구매담당자와의 커뮤니케이션
+ </CardDescription>
+ </div>
+ <Button
+ onClick={() => setCommunicationDrawerOpen(true)}
+ variant="outline"
+ size="sm"
+ >
+ <MessageSquare className="h-4 w-4 mr-2" />
+ {unreadCount > 0 ? "새 메시지 확인" : "메시지 보내기"}
+ </Button>
+ </CardHeader>
+ <CardContent>
+ <div className="flex items-center gap-4 text-sm text-muted-foreground">
+ <span>구매담당자: {quotation.rfq?.createdByUser?.name || "N/A"}</span>
+ <span>•</span>
+ <span>벤더: {quotation.vendor?.vendorName}</span>
+ </div>
+ </CardContent>
+ </Card>
+
+ {/* 메시지 미리보기 */}
+ <Card className="flex-1 flex flex-col min-h-0">
+ <CardHeader>
+ <CardTitle className="text-lg">메시지 ({comments.length})</CardTitle>
+ </CardHeader>
+ <CardContent>
+ {loadingComments ? (
+ <div className="flex items-center justify-center p-8">
+ <div className="text-center">
+ <Skeleton className="h-4 w-32 mx-auto mb-2" />
+ <Skeleton className="h-4 w-48 mx-auto" />
+ </div>
+ </div>
+ ) : comments.length === 0 ? (
+ <div className="min-h-[200px] flex flex-col items-center justify-center text-center p-8">
+ <div className="max-w-md">
+ <div className="mx-auto bg-primary/10 rounded-full w-12 h-12 flex items-center justify-center mb-4">
+ <MessageSquare className="h-6 w-6 text-primary" />
+ </div>
+ <h3 className="text-lg font-medium mb-2">아직 메시지가 없습니다</h3>
+ <p className="text-muted-foreground mb-4">
+ 견적서에 대한 질문이나 의견이 있으신가요? 구매자와 메시지를 주고받으세요.
+ </p>
+ <Button
+ onClick={() => setCommunicationDrawerOpen(true)}
+ className="mx-auto"
+ >
+ 메시지 보내기
+ </Button>
+ </div>
+ </div>
+ ) : (
+ <div className="space-y-4">
+ {/* 최근 메시지 3개 미리보기 */}
+ <div className="space-y-2">
+ <h3 className="text-sm font-medium">최근 메시지</h3>
+ <ScrollArea className="h-[250px] rounded-md border p-4">
+ {comments.slice(-3).map(comment => (
+ <div
+ key={comment.id}
+ className={`p-3 mb-3 rounded-lg ${!comment.isVendorComment && !comment.isRead
+ ? 'bg-primary/10 border-l-4 border-primary'
+ : 'bg-muted/50'
+ }`}
+ >
+ <div className="flex justify-between items-center mb-1">
+ <span className="text-sm font-medium">
+ {comment.isVendorComment
+ ? '나'
+ : comment.userName || '구매 담당자'}
+ </span>
+ <span className="text-xs text-muted-foreground">
+ {new Date(comment.createdAt).toLocaleDateString()}
+ </span>
+ </div>
+ <p className="text-sm line-clamp-2">{comment.content}</p>
+ {comment.attachments.length > 0 && (
+ <div className="mt-1 text-xs text-muted-foreground">
+ <Paperclip className="h-3 w-3 inline mr-1" />
+ 첨부파일 {comment.attachments.length}개
+ </div>
+ )}
+ </div>
+ ))}
+ </ScrollArea>
+ </div>
+
+ <div className="flex justify-center">
+ <Button
+ onClick={() => setCommunicationDrawerOpen(true)}
+ className="w-full"
+ >
+ 전체 메시지 보기 ({comments.length}개)
+ </Button>
+ </div>
+ </div>
+ )}
+ </CardContent>
+ </Card>
+
+ {/* 커뮤니케이션 드로어 */}
+ <BuyerCommunicationDrawer
+ open={communicationDrawerOpen}
+ onOpenChange={handleCommunicationDrawerChange}
+ quotation={{
+ id: quotation.id,
+ rfqId: quotation.rfqId,
+ vendorId: quotation.vendorId,
+ quotationCode: quotation.quotationCode,
+ rfq: quotation.rfq ? {
+ rfqCode: quotation.rfq.rfqCode
+ } : undefined
+ }}
+ onSuccess={loadCommunicationData}
+ />
+ </div>
+ )
}
\ No newline at end of file diff --git a/lib/techsales-rfq/vendor-response/detail/project-info-tab.tsx b/lib/techsales-rfq/vendor-response/detail/project-info-tab.tsx index a8f44474..771db896 100644 --- a/lib/techsales-rfq/vendor-response/detail/project-info-tab.tsx +++ b/lib/techsales-rfq/vendor-response/detail/project-info-tab.tsx @@ -1,149 +1,149 @@ -"use client" - -import * as React from "react" -import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card" -import { Badge } from "@/components/ui/badge" -import { ScrollArea } from "@/components/ui/scroll-area" -import { formatDate } from "@/lib/utils" - -interface ProjectInfoTabProps { - quotation: { - id: number - rfq: { - id: number - rfqCode: string | null - materialCode: string | null - dueDate: Date | null - status: string | null - remark: string | null - biddingProject?: { - id: number - pspid: string | null - projNm: string | null - sector: string | null - projMsrm: string | null - ptypeNm: string | null - } | null - createdByUser?: { - id: number - name: string | null - email: string | null - } | null - } | null - vendor: { - id: number - vendorName: string - vendorCode: string | null - } | null - } -} - -export function ProjectInfoTab({ quotation }: ProjectInfoTabProps) { - const rfq = quotation.rfq - - console.log("rfq: ", rfq) - - if (!rfq) { - return ( - <div className="flex items-center justify-center h-full"> - <div className="text-center"> - <h3 className="text-lg font-medium">RFQ 정보를 찾을 수 없습니다</h3> - <p className="text-sm text-muted-foreground mt-1"> - 연결된 RFQ 정보가 없습니다. - </p> - </div> - </div> - ) - } - - return ( - <ScrollArea className="h-full"> - <div className="space-y-6 p-1"> - {/* RFQ 기본 정보 */} - <Card> - <CardHeader> - <CardTitle className="flex items-center gap-2"> - RFQ 기본 정보 - <Badge variant="outline">{rfq.rfqCode || "미할당"}</Badge> - </CardTitle> - <CardDescription> - 요청서 기본 정보 및 자재 정보 - </CardDescription> - </CardHeader> - <CardContent className="space-y-4"> - <div className="grid grid-cols-1 md:grid-cols-2 gap-4"> - <div className="space-y-2"> - <div className="text-sm font-medium text-muted-foreground">RFQ 번호</div> - <div className="text-sm">{rfq.rfqCode || "미할당"}</div> - </div> - <div className="space-y-2"> - <div className="text-sm font-medium text-muted-foreground">자재 그룹</div> - <div className="text-sm">{rfq.materialCode || "N/A"}</div> - </div> - <div className="space-y-2"> - <div className="text-sm font-medium text-muted-foreground">마감일</div> - <div className="text-sm"> - {rfq.dueDate ? formatDate(rfq.dueDate) : "N/A"} - </div> - </div> - <div className="space-y-2"> - <div className="text-sm font-medium text-muted-foreground">RFQ 상태</div> - <div className="text-sm">{rfq.status || "N/A"}</div> - </div> - <div className="space-y-2"> - <div className="text-sm font-medium text-muted-foreground">담당자</div> - <div className="text-sm">{rfq.createdByUser?.name || "N/A"}</div> - </div> - </div> - {rfq.remark && ( - <div className="space-y-2"> - <div className="text-sm font-medium text-muted-foreground">비고</div> - <div className="text-sm p-3 bg-muted rounded-md">{rfq.remark}</div> - </div> - )} - </CardContent> - </Card> - - {/* 프로젝트 기본 정보 */} - {rfq.biddingProject && ( - <Card> - <CardHeader> - <CardTitle className="flex items-center gap-2"> - 프로젝트 기본 정보 - <Badge variant="outline">{rfq.biddingProject.pspid || "N/A"}</Badge> - </CardTitle> - <CardDescription> - 연결된 프로젝트의 기본 정보 - </CardDescription> - </CardHeader> - <CardContent className="space-y-4"> - <div className="grid grid-cols-1 md:grid-cols-2 gap-4"> - <div className="space-y-2"> - <div className="text-sm font-medium text-muted-foreground">프로젝트 ID</div> - <div className="text-sm">{rfq.biddingProject.pspid || "N/A"}</div> - </div> - <div className="space-y-2"> - <div className="text-sm font-medium text-muted-foreground">프로젝트명</div> - <div className="text-sm">{rfq.biddingProject.projNm || "N/A"}</div> - </div> - <div className="space-y-2"> - <div className="text-sm font-medium text-muted-foreground">프로젝트 섹터</div> - <div className="text-sm">{rfq.biddingProject.sector || "N/A"}</div> - </div> - <div className="space-y-2"> - <div className="text-sm font-medium text-muted-foreground">프로젝트 규모</div> - <div className="text-sm">{rfq.biddingProject.projMsrm || "N/A"}</div> - </div> - <div className="space-y-2"> - <div className="text-sm font-medium text-muted-foreground">프로젝트 타입</div> - <div className="text-sm">{rfq.biddingProject.ptypeNm || "N/A"}</div> - </div> - </div> - </CardContent> - </Card> - )} - - </div> - </ScrollArea> - ) +"use client"
+
+import * as React from "react"
+import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
+import { Badge } from "@/components/ui/badge"
+import { ScrollArea } from "@/components/ui/scroll-area"
+import { formatDate } from "@/lib/utils"
+
+interface ProjectInfoTabProps {
+ quotation: {
+ id: number
+ rfq: {
+ id: number
+ rfqCode: string | null
+ materialCode: string | null
+ dueDate: Date | null
+ status: string | null
+ remark: string | null
+ biddingProject?: {
+ id: number
+ pspid: string | null
+ projNm: string | null
+ sector: string | null
+ projMsrm: string | null
+ ptypeNm: string | null
+ } | null
+ createdByUser?: {
+ id: number
+ name: string | null
+ email: string | null
+ } | null
+ } | null
+ vendor: {
+ id: number
+ vendorName: string
+ vendorCode: string | null
+ } | null
+ }
+}
+
+export function ProjectInfoTab({ quotation }: ProjectInfoTabProps) {
+ const rfq = quotation.rfq
+
+ console.log("rfq: ", rfq)
+
+ if (!rfq) {
+ return (
+ <div className="flex items-center justify-center h-full">
+ <div className="text-center">
+ <h3 className="text-lg font-medium">RFQ 정보를 찾을 수 없습니다</h3>
+ <p className="text-sm text-muted-foreground mt-1">
+ 연결된 RFQ 정보가 없습니다.
+ </p>
+ </div>
+ </div>
+ )
+ }
+
+ return (
+ <ScrollArea className="h-full">
+ <div className="space-y-6 p-1">
+ {/* RFQ 기본 정보 */}
+ <Card>
+ <CardHeader>
+ <CardTitle className="flex items-center gap-2">
+ RFQ 기본 정보
+ <Badge variant="outline">{rfq.rfqCode || "미할당"}</Badge>
+ </CardTitle>
+ <CardDescription>
+ 요청서 기본 정보 및 자재 정보
+ </CardDescription>
+ </CardHeader>
+ <CardContent className="space-y-4">
+ <div className="grid grid-cols-1 md:grid-cols-2 gap-4">
+ <div className="space-y-2">
+ <div className="text-sm font-medium text-muted-foreground">RFQ 번호</div>
+ <div className="text-sm">{rfq.rfqCode || "미할당"}</div>
+ </div>
+ <div className="space-y-2">
+ <div className="text-sm font-medium text-muted-foreground">자재 그룹</div>
+ <div className="text-sm">{rfq.materialCode || "N/A"}</div>
+ </div>
+ <div className="space-y-2">
+ <div className="text-sm font-medium text-muted-foreground">마감일</div>
+ <div className="text-sm">
+ {rfq.dueDate ? formatDate(rfq.dueDate) : "N/A"}
+ </div>
+ </div>
+ <div className="space-y-2">
+ <div className="text-sm font-medium text-muted-foreground">RFQ 상태</div>
+ <div className="text-sm">{rfq.status || "N/A"}</div>
+ </div>
+ <div className="space-y-2">
+ <div className="text-sm font-medium text-muted-foreground">담당자</div>
+ <div className="text-sm">{rfq.createdByUser?.name || "N/A"}</div>
+ </div>
+ </div>
+ {rfq.remark && (
+ <div className="space-y-2">
+ <div className="text-sm font-medium text-muted-foreground">비고</div>
+ <div className="text-sm p-3 bg-muted rounded-md">{rfq.remark}</div>
+ </div>
+ )}
+ </CardContent>
+ </Card>
+
+ {/* 프로젝트 기본 정보 */}
+ {rfq.biddingProject && (
+ <Card>
+ <CardHeader>
+ <CardTitle className="flex items-center gap-2">
+ 프로젝트 기본 정보
+ <Badge variant="outline">{rfq.biddingProject.pspid || "N/A"}</Badge>
+ </CardTitle>
+ <CardDescription>
+ 연결된 프로젝트의 기본 정보
+ </CardDescription>
+ </CardHeader>
+ <CardContent className="space-y-4">
+ <div className="grid grid-cols-1 md:grid-cols-2 gap-4">
+ <div className="space-y-2">
+ <div className="text-sm font-medium text-muted-foreground">프로젝트 ID</div>
+ <div className="text-sm">{rfq.biddingProject.pspid || "N/A"}</div>
+ </div>
+ <div className="space-y-2">
+ <div className="text-sm font-medium text-muted-foreground">프로젝트명</div>
+ <div className="text-sm">{rfq.biddingProject.projNm || "N/A"}</div>
+ </div>
+ <div className="space-y-2">
+ <div className="text-sm font-medium text-muted-foreground">프로젝트 섹터</div>
+ <div className="text-sm">{rfq.biddingProject.sector || "N/A"}</div>
+ </div>
+ <div className="space-y-2">
+ <div className="text-sm font-medium text-muted-foreground">프로젝트 규모</div>
+ <div className="text-sm">{rfq.biddingProject.projMsrm || "N/A"}</div>
+ </div>
+ <div className="space-y-2">
+ <div className="text-sm font-medium text-muted-foreground">프로젝트 타입</div>
+ <div className="text-sm">{rfq.biddingProject.ptypeNm || "N/A"}</div>
+ </div>
+ </div>
+ </CardContent>
+ </Card>
+ )}
+
+ </div>
+ </ScrollArea>
+ )
}
\ No newline at end of file diff --git a/lib/techsales-rfq/vendor-response/detail/quotation-response-tab.tsx b/lib/techsales-rfq/vendor-response/detail/quotation-response-tab.tsx index 0425ccc9..0a56b702 100644 --- a/lib/techsales-rfq/vendor-response/detail/quotation-response-tab.tsx +++ b/lib/techsales-rfq/vendor-response/detail/quotation-response-tab.tsx @@ -1,522 +1,523 @@ -"use client" - -import * as React from "react" -import { useState, useEffect } from "react" -import { useRouter } from "next/navigation" -import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card" -import { Button } from "@/components/ui/button" -import { Input } from "@/components/ui/input" -import { Label } from "@/components/ui/label" -import { Textarea } from "@/components/ui/textarea" -import { Badge } from "@/components/ui/badge" -import { ScrollArea } from "@/components/ui/scroll-area" -import { CalendarIcon, Send, AlertCircle, Upload, X, FileText, Download } from "lucide-react" -import { Calendar } from "@/components/ui/calendar" -import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover" -import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select" -import { Alert, AlertDescription } from "@/components/ui/alert" -import { formatDate, cn } from "@/lib/utils" -import { toast } from "sonner" -import { useSession } from "next-auth/react" - -interface QuotationResponseTabProps { - quotation: { - id: number - status: string - totalPrice: string | null - currency: string | null - validUntil: Date | null - remark: string | null - quotationAttachments?: Array<{ - id: number - fileName: string - fileSize: number - filePath: string - description?: string | null - }> - rfq: { - id: number - rfqCode: string | null - materialCode: string | null - dueDate: Date | null - status: string | null - item?: { - itemName: string | null - } | null - } | null - vendor: { - vendorName: string - } | null - } -} - -const CURRENCIES = [ - { value: "KRW", label: "KRW (원)" }, - { value: "USD", label: "USD (달러)" }, - { value: "EUR", label: "EUR (유로)" }, - { value: "JPY", label: "JPY (엔)" }, - { value: "CNY", label: "CNY (위안)" }, -] - -export function QuotationResponseTab({ quotation }: QuotationResponseTabProps) { - const [totalPrice, setTotalPrice] = useState(quotation.totalPrice?.toString() || "") - const [currency, setCurrency] = useState(quotation.currency || "KRW") - const [validUntil, setValidUntil] = useState<Date | undefined>( - quotation.validUntil ? new Date(quotation.validUntil) : undefined - ) - const [remark, setRemark] = useState(quotation.remark || "") - const [isLoading, setIsLoading] = useState(false) - const [attachments, setAttachments] = useState<Array<{ - id?: number - fileName: string - fileSize: number - filePath: string - isNew?: boolean - file?: File - }>>([]) - const [isUploadingFiles, setIsUploadingFiles] = useState(false) - const router = useRouter() - const session = useSession() - - // // 초기 첨부파일 데이터 로드 - // useEffect(() => { - // if (quotation.quotationAttachments) { - // setAttachments(quotation.quotationAttachments.map(att => ({ - // id: att.id, - // fileName: att.fileName, - // fileSize: att.fileSize, - // filePath: att.filePath, - // isNew: false - // }))) - // } - // }, [quotation.quotationAttachments]) - - const rfq = quotation.rfq - const isDueDatePassed = rfq?.dueDate ? new Date(rfq.dueDate) < new Date() : false - const canSubmit = !["Accepted", "Rejected"].includes(quotation.status) && !isDueDatePassed - const canEdit = !["Accepted", "Rejected"].includes(quotation.status) && !isDueDatePassed - - // 파일 업로드 핸들러 - const handleFileSelect = (event: React.ChangeEvent<HTMLInputElement>) => { - const files = event.target.files - if (!files) return - - Array.from(files).forEach(file => { - setAttachments(prev => [ - ...prev, - { - fileName: file.name, - fileSize: file.size, - filePath: '', - isNew: true, - file - } - ]) - }) - } - - // 첨부파일 제거 - const removeAttachment = (index: number) => { - setAttachments(prev => prev.filter((_, i) => i !== index)) - } - - // 파일 업로드 함수 - const uploadFiles = async () => { - const newFiles = attachments.filter(att => att.isNew && att.file) - if (newFiles.length === 0) return [] - - setIsUploadingFiles(true) - const uploadedFiles = [] - - try { - for (const attachment of newFiles) { - const formData = new FormData() - formData.append('file', attachment.file!) - - const response = await fetch('/api/upload', { - method: 'POST', - body: formData - }) - - if (!response.ok) throw new Error('파일 업로드 실패') - - const result = await response.json() - uploadedFiles.push({ - fileName: result.fileName, - filePath: result.url, - fileSize: attachment.fileSize - }) - } - return uploadedFiles - } catch (error) { - console.error('파일 업로드 오류:', error) - toast.error('파일 업로드 중 오류가 발생했습니다.') - return [] - } finally { - setIsUploadingFiles(false) - } - } - - const handleSubmit = async () => { - if (!totalPrice || !currency || !validUntil) { - toast.error("모든 필수 항목을 입력해주세요.") - return - } - - setIsLoading(true) - try { - // 파일 업로드 먼저 처리 - const uploadedFiles = await uploadFiles() - - const { submitTechSalesVendorQuotation } = await import("@/lib/techsales-rfq/service") - - const result = await submitTechSalesVendorQuotation({ - id: quotation.id, - currency, - totalPrice, - validUntil: validUntil!, - remark, - attachments: uploadedFiles, - updatedBy: parseInt(session.data?.user.id || "0") - }) - - if (result.error) { - toast.error(result.error) - } else { - toast.success("견적서가 제출되었습니다.") - // // 페이지 새로고침 대신 router.refresh() 사용 - // router.refresh() - // 페이지 새로고침 - window.location.reload() - } - } catch { - toast.error("제출 중 오류가 발생했습니다.") - } finally { - setIsLoading(false) - } - } - - const getStatusBadgeVariant = (status: string) => { - switch (status) { - case "Draft": - return "secondary" - case "Submitted": - return "default" - case "Revised": - return "outline" - case "Rejected": - return "destructive" - case "Accepted": - return "success" - default: - return "secondary" - } - } - - const getStatusLabel = (status: string) => { - switch (status) { - case "Draft": - return "초안" - case "Submitted": - return "제출됨" - case "Revised": - return "수정됨" - case "Rejected": - return "반려됨" - case "Accepted": - return "승인됨" - default: - return status - } - } - - return ( - <ScrollArea className="h-full"> - <div className="space-y-6 p-1"> - {/* 견적서 상태 정보 */} - <Card> - <CardHeader> - <CardTitle className="flex items-center gap-2"> - 견적서 상태 - <Badge variant={getStatusBadgeVariant(quotation.status)}> - {getStatusLabel(quotation.status)} - </Badge> - </CardTitle> - <CardDescription> - 현재 견적서 상태 및 마감일 정보 - </CardDescription> - </CardHeader> - <CardContent className="space-y-4"> - <div className="grid grid-cols-1 md:grid-cols-3 gap-4"> - <div className="space-y-2"> - <div className="text-sm font-medium text-muted-foreground">견적서 상태</div> - <div className="text-sm">{getStatusLabel(quotation.status)}</div> - </div> - <div className="space-y-2"> - <div className="text-sm font-medium text-muted-foreground">RFQ 마감일</div> - <div className="text-sm"> - {rfq?.dueDate ? formatDate(rfq.dueDate) : "N/A"} - </div> - </div> - <div className="space-y-2"> - <div className="text-sm font-medium text-muted-foreground">남은 시간</div> - <div className="text-sm"> - {isDueDatePassed ? ( - <span className="text-destructive">마감됨</span> - ) : rfq?.dueDate ? ( - <span className="text-green-600"> - {Math.ceil((new Date(rfq.dueDate).getTime() - new Date().getTime()) / (1000 * 60 * 60 * 24))}일 - </span> - ) : ( - "N/A" - )} - </div> - </div> - </div> - - {isDueDatePassed && ( - <Alert> - <AlertCircle className="h-4 w-4" /> - <AlertDescription> - RFQ 마감일이 지났습니다. 견적서를 수정하거나 제출할 수 없습니다. - </AlertDescription> - </Alert> - )} - - {!canEdit && !isDueDatePassed && ( - <Alert> - <AlertCircle className="h-4 w-4" /> - <AlertDescription> - 현재 상태에서는 견적서를 수정할 수 없습니다. - </AlertDescription> - </Alert> - )} - </CardContent> - </Card> - - {/* 견적 응답 폼 */} - <Card> - <CardHeader> - <CardTitle>견적 응답</CardTitle> - <CardDescription> - 총 가격, 통화, 유효기간을 입력해주세요. - </CardDescription> - </CardHeader> - <CardContent className="space-y-6"> - {/* 총 가격 */} - <div className="space-y-2"> - <Label htmlFor="totalPrice"> - 총 가격 <span className="text-destructive">*</span> - </Label> - <Input - id="totalPrice" - type="number" - placeholder="총 가격을 입력하세요" - value={totalPrice} - onChange={(e) => setTotalPrice(e.target.value)} - disabled={!canEdit} - className="text-right" - /> - </div> - - {/* 통화 */} - <div className="space-y-2"> - <Label htmlFor="currency"> - 통화 <span className="text-destructive">*</span> - </Label> - <Select value={currency} onValueChange={setCurrency} disabled={!canEdit}> - <SelectTrigger> - <SelectValue placeholder="통화를 선택하세요" /> - </SelectTrigger> - <SelectContent> - {CURRENCIES.map((curr) => ( - <SelectItem key={curr.value} value={curr.value}> - {curr.label} - </SelectItem> - ))} - </SelectContent> - </Select> - </div> - - {/* 유효기간 */} - <div className="space-y-2"> - <Label> - 견적 유효기간 <span className="text-destructive">*</span> - </Label> - <Popover> - <PopoverTrigger asChild> - <Button - variant="outline" - className={cn( - "w-full justify-start text-left font-normal", - !validUntil && "text-muted-foreground" - )} - disabled={!canEdit} - > - <CalendarIcon className="mr-2 h-4 w-4" /> - {validUntil ? formatDate(validUntil) : "날짜를 선택하세요"} - </Button> - </PopoverTrigger> - <PopoverContent className="w-auto p-0" align="start"> - <Calendar - mode="single" - selected={validUntil} - onSelect={setValidUntil} - disabled={(date) => date < new Date()} - initialFocus - /> - </PopoverContent> - </Popover> - </div> - - {/* 비고 */} - <div className="space-y-2"> - <Label htmlFor="remark">비고</Label> - <Textarea - id="remark" - placeholder="추가 설명이나 조건을 입력하세요" - value={remark} - onChange={(e) => setRemark(e.target.value)} - disabled={!canEdit} - rows={4} - /> - </div> - - {/* 첨부파일 */} - <div className="space-y-4"> - <Label>첨부파일</Label> - - {/* 파일 업로드 버튼 */} - {canEdit && ( - <div className="flex items-center gap-2"> - <Button - type="button" - variant="outline" - size="sm" - disabled={isUploadingFiles} - onClick={() => document.getElementById('file-input')?.click()} - > - <Upload className="h-4 w-4 mr-2" /> - 파일 선택 - </Button> - <input - id="file-input" - type="file" - multiple - onChange={handleFileSelect} - className="hidden" - accept=".pdf,.doc,.docx,.xls,.xlsx,.ppt,.pptx,.txt,.jpg,.jpeg,.png,.zip" - /> - <span className="text-sm text-muted-foreground"> - PDF, 문서파일, 이미지파일, 압축파일 등 - </span> - </div> - )} - - {/* 첨부파일 목록 */} - {attachments.length > 0 && ( - <div className="space-y-2"> - {attachments.map((attachment, index) => ( - <div - key={index} - className="flex items-center justify-between p-3 border rounded-lg bg-muted/50" - > - <div className="flex items-center gap-2"> - <FileText className="h-4 w-4 text-muted-foreground" /> - <div> - <div className="text-sm font-medium">{attachment.fileName}</div> - <div className="text-xs text-muted-foreground"> - {(attachment.fileSize / 1024 / 1024).toFixed(2)} MB - {attachment.isNew && ( - <Badge variant="secondary" className="ml-2"> - 새 파일 - </Badge> - )} - </div> - </div> - </div> - <div className="flex items-center gap-2"> - {!attachment.isNew && ( - <Button - type="button" - variant="ghost" - size="sm" - onClick={() => window.open(attachment.filePath, '_blank')} - > - <Download className="h-4 w-4" /> - </Button> - )} - {canEdit && ( - <Button - type="button" - variant="ghost" - size="sm" - onClick={() => removeAttachment(index)} - > - <X className="h-4 w-4" /> - </Button> - )} - </div> - </div> - ))} - </div> - )} - </div> - - {/* 액션 버튼 */} - {canEdit && canSubmit && ( - <div className="flex justify-center pt-4"> - <Button - onClick={handleSubmit} - disabled={isLoading || !totalPrice || !currency || !validUntil} - className="w-full " - > - <Send className="mr-2 h-4 w-4" /> - 견적서 제출 - </Button> - </div> - )} - </CardContent> - </Card> - - {/* 현재 견적 정보 (읽기 전용) */} - {quotation.totalPrice && ( - <Card> - <CardHeader> - <CardTitle>현재 견적 정보</CardTitle> - <CardDescription> - 저장된 견적 정보 - </CardDescription> - </CardHeader> - <CardContent className="space-y-4"> - <div className="grid grid-cols-1 md:grid-cols-3 gap-4"> - <div className="space-y-2"> - <div className="text-sm font-medium text-muted-foreground">총 가격</div> - <div className="text-lg font-semibold"> - {parseFloat(quotation.totalPrice).toLocaleString()} {quotation.currency} - </div> - </div> - <div className="space-y-2"> - <div className="text-sm font-medium text-muted-foreground">통화</div> - <div className="text-sm">{quotation.currency}</div> - </div> - <div className="space-y-2"> - <div className="text-sm font-medium text-muted-foreground">유효기간</div> - <div className="text-sm"> - {quotation.validUntil ? formatDate(quotation.validUntil) : "N/A"} - </div> - </div> - </div> - {quotation.remark && ( - <div className="space-y-2"> - <div className="text-sm font-medium text-muted-foreground">비고</div> - <div className="text-sm p-3 bg-muted rounded-md">{quotation.remark}</div> - </div> - )} - </CardContent> - </Card> - )} - </div> - </ScrollArea> - ) +"use client"
+
+import * as React from "react"
+import { useState } from "react"
+import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
+import { Button } from "@/components/ui/button"
+import { Input } from "@/components/ui/input"
+import { Label } from "@/components/ui/label"
+import { Textarea } from "@/components/ui/textarea"
+import { Badge } from "@/components/ui/badge"
+import { ScrollArea } from "@/components/ui/scroll-area"
+import { CalendarIcon, Send, AlertCircle, X, FileText, Download, History, FileIcon } from "lucide-react"
+import { Calendar } from "@/components/ui/calendar"
+import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"
+import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
+import { Alert, AlertDescription } from "@/components/ui/alert"
+import { formatDate, cn } from "@/lib/utils"
+import { toast } from "sonner"
+import { useSession } from "next-auth/react"
+import { QuotationHistoryDialog } from "@/lib/techsales-rfq/table/detail-table/quotation-history-dialog"
+
+interface QuotationResponseTabProps {
+ quotation: {
+ id: number
+ status: string
+ totalPrice: string | null
+ currency: string | null
+ validUntil: Date | null
+ remark: string | null
+ quotationAttachments?: Array<{
+ id: number
+ fileName: string
+ fileSize: number
+ filePath: string
+ description?: string | null
+ }>
+ rfq: {
+ id: number
+ rfqCode: string | null
+ materialCode: string | null
+ dueDate: Date | null
+ status: string | null
+ item?: {
+ itemName: string | null
+ } | null
+ } | null
+ vendor: {
+ vendorName: string
+ } | null
+ }
+}
+
+const CURRENCIES = [
+ { value: "KRW", label: "KRW (원)" },
+ { value: "USD", label: "USD (달러)" },
+ { value: "EUR", label: "EUR (유로)" },
+ { value: "JPY", label: "JPY (엔)" },
+ { value: "CNY", label: "CNY (위안)" },
+]
+
+export function QuotationResponseTab({ quotation }: QuotationResponseTabProps) {
+ const [totalPrice, setTotalPrice] = useState(quotation.totalPrice?.toString() || "")
+ const [currency, setCurrency] = useState(quotation.currency || "KRW")
+ const [validUntil, setValidUntil] = useState<Date | undefined>(
+ quotation.validUntil ? new Date(quotation.validUntil) : undefined
+ )
+ const [remark, setRemark] = useState(quotation.remark || "")
+ const [isLoading, setIsLoading] = useState(false)
+ const [attachments, setAttachments] = useState<Array<{
+ id?: number
+ fileName: string
+ fileSize: number
+ filePath: string
+ isNew?: boolean
+ file?: File
+ }>>([])
+ const [isUploadingFiles, setIsUploadingFiles] = useState(false)
+
+ // 견적 히스토리 다이얼로그 상태 관리
+ const [historyDialogOpen, setHistoryDialogOpen] = useState(false)
+
+ const session = useSession()
+
+ // // 초기 첨부파일 데이터 로드
+ // useEffect(() => {
+ // if (quotation.quotationAttachments) {
+ // setAttachments(quotation.quotationAttachments.map(att => ({
+ // id: att.id,
+ // fileName: att.fileName,
+ // fileSize: att.fileSize,
+ // filePath: att.filePath,
+ // isNew: false
+ // })))
+ // }
+ // }, [quotation.quotationAttachments])
+
+ const rfq = quotation.rfq
+ const isDueDatePassed = rfq?.dueDate ? new Date(rfq.dueDate) < new Date() : false
+ const canSubmit = !["Accepted", "Rejected"].includes(quotation.status) && !isDueDatePassed
+ const canEdit = !["Accepted", "Rejected"].includes(quotation.status) && !isDueDatePassed
+
+ // 파일 업로드 핸들러
+ const handleFileSelect = (event: React.ChangeEvent<HTMLInputElement>) => {
+ const files = event.target.files
+ if (!files) return
+
+ Array.from(files).forEach(file => {
+ setAttachments(prev => [
+ ...prev,
+ {
+ fileName: file.name,
+ fileSize: file.size,
+ filePath: '',
+ isNew: true,
+ file
+ }
+ ])
+ })
+ }
+
+ // 첨부파일 제거
+ const removeAttachment = (index: number) => {
+ setAttachments(prev => prev.filter((_, i) => i !== index))
+ }
+
+ // 파일 업로드 함수
+ const uploadFiles = async () => {
+ const newFiles = attachments.filter(att => att.isNew && att.file)
+ if (newFiles.length === 0) return []
+
+ setIsUploadingFiles(true)
+
+ try {
+ // 서비스 함수를 사용하여 파일 업로드
+ const { uploadQuotationAttachments } = await import('@/lib/techsales-rfq/service')
+
+ const files = newFiles.map(att => att.file!).filter(Boolean)
+ const userId = parseInt(session.data?.user.id || "0")
+
+ const result = await uploadQuotationAttachments(quotation.id, files, userId)
+
+ if (result.success && result.attachments) {
+ return result.attachments
+ } else {
+ throw new Error(result.error || '파일 저장에 실패했습니다.')
+ }
+ } catch (error) {
+ console.error('파일 업로드 오류:', error)
+ toast.error('파일 업로드 중 오류가 발생했습니다.')
+ return []
+ } finally {
+ setIsUploadingFiles(false)
+ }
+ }
+
+ const handleSubmit = async () => {
+ if (!totalPrice || !currency || !validUntil) {
+ toast.error("모든 필수 항목을 입력해주세요.")
+ return
+ }
+
+ setIsLoading(true)
+ try {
+ // 파일 업로드 먼저 처리
+ const uploadedFiles = await uploadFiles()
+
+ const { submitTechSalesVendorQuotation } = await import("@/lib/techsales-rfq/service")
+
+ const result = await submitTechSalesVendorQuotation({
+ id: quotation.id,
+ currency,
+ totalPrice,
+ validUntil: validUntil!,
+ remark,
+ attachments: uploadedFiles,
+ updatedBy: parseInt(session.data?.user.id || "0")
+ })
+
+ if (result.error) {
+ toast.error(result.error)
+ } else {
+ toast.success("견적서가 제출되었습니다.")
+ // // 페이지 새로고침 대신 router.refresh() 사용
+ // router.refresh()
+ // 페이지 새로고침
+ window.location.reload()
+ }
+ } catch {
+ toast.error("제출 중 오류가 발생했습니다.")
+ } finally {
+ setIsLoading(false)
+ }
+ }
+
+ const getStatusBadgeVariant = (status: string) => {
+ switch (status) {
+ case "Draft":
+ return "secondary"
+ case "Submitted":
+ return "default"
+ case "Revised":
+ return "outline"
+ case "Rejected":
+ return "destructive"
+ case "Accepted":
+ return "success"
+ default:
+ return "secondary"
+ }
+ }
+
+ const getStatusLabel = (status: string) => {
+ switch (status) {
+ case "Draft":
+ return "초안"
+ case "Submitted":
+ return "제출됨"
+ case "Revised":
+ return "수정됨"
+ case "Rejected":
+ return "반려됨"
+ case "Accepted":
+ return "승인됨"
+ default:
+ return status
+ }
+ }
+
+ return (
+ <ScrollArea className="h-full">
+ <div className="space-y-6 p-1">
+ {/* 견적서 상태 정보 */}
+ <Card>
+ <CardHeader>
+ <div className="flex items-center justify-between">
+ <div>
+ <CardTitle className="flex items-center gap-2">
+ 견적서 상태
+ <Badge variant={getStatusBadgeVariant(quotation.status)}>
+ {getStatusLabel(quotation.status)}
+ </Badge>
+ </CardTitle>
+ <CardDescription>
+ 현재 견적서 상태 및 마감일 정보
+ </CardDescription>
+ </div>
+
+ {/* 견적 히스토리 보기 버튼 */}
+ <Button
+ variant="outline"
+ size="sm"
+ onClick={() => setHistoryDialogOpen(true)}
+ className="flex items-center gap-2"
+ >
+ <History className="h-4 w-4" />
+ 이전 견적 히스토리 보기
+ </Button>
+ </div>
+ </CardHeader>
+ <CardContent className="space-y-4">
+ <div className="grid grid-cols-1 md:grid-cols-3 gap-4">
+ <div className="space-y-2">
+ <div className="text-sm font-medium text-muted-foreground">견적서 상태</div>
+ <div className="text-sm">{getStatusLabel(quotation.status)}</div>
+ </div>
+ <div className="space-y-2">
+ <div className="text-sm font-medium text-muted-foreground">RFQ 마감일</div>
+ <div className="text-sm">
+ {rfq?.dueDate ? formatDate(rfq.dueDate) : "N/A"}
+ </div>
+ </div>
+ <div className="space-y-2">
+ <div className="text-sm font-medium text-muted-foreground">남은 시간</div>
+ <div className="text-sm">
+ {isDueDatePassed ? (
+ <span className="text-destructive">마감됨</span>
+ ) : rfq?.dueDate ? (
+ <span className="text-green-600">
+ {Math.ceil((new Date(rfq.dueDate).getTime() - new Date().getTime()) / (1000 * 60 * 60 * 24))}일
+ </span>
+ ) : (
+ "N/A"
+ )}
+ </div>
+ </div>
+ </div>
+
+ {isDueDatePassed && (
+ <Alert>
+ <AlertCircle className="h-4 w-4" />
+ <AlertDescription>
+ RFQ 마감일이 지났습니다. 견적서를 수정하거나 제출할 수 없습니다.
+ </AlertDescription>
+ </Alert>
+ )}
+
+ {!canEdit && !isDueDatePassed && (
+ <Alert>
+ <AlertCircle className="h-4 w-4" />
+ <AlertDescription>
+ 현재 상태에서는 견적서를 수정할 수 없습니다.
+ </AlertDescription>
+ </Alert>
+ )}
+ </CardContent>
+ </Card>
+
+ {/* 견적 응답 폼 */}
+ <Card>
+ <CardHeader>
+ <CardTitle>견적 응답</CardTitle>
+ <CardDescription>
+ 견적 정보를 입력하고 제출하세요
+ </CardDescription>
+ </CardHeader>
+ <CardContent className="space-y-4">
+ <div className="grid grid-cols-1 md:grid-cols-2 gap-4">
+ <div className="space-y-2">
+ <Label htmlFor="totalPrice">견적 금액 *</Label>
+ <Input
+ id="totalPrice"
+ type="number"
+ value={totalPrice}
+ onChange={(e) => setTotalPrice(e.target.value)}
+ placeholder="견적 금액을 입력하세요"
+ disabled={!canEdit}
+ />
+ </div>
+ <div className="space-y-2">
+ <Label htmlFor="currency">통화 *</Label>
+ <Select value={currency} onValueChange={setCurrency} disabled={!canEdit}>
+ <SelectTrigger>
+ <SelectValue placeholder="통화를 선택하세요" />
+ </SelectTrigger>
+ <SelectContent>
+ {CURRENCIES.map((curr) => (
+ <SelectItem key={curr.value} value={curr.value}>
+ {curr.label}
+ </SelectItem>
+ ))}
+ </SelectContent>
+ </Select>
+ </div>
+ </div>
+
+ <div className="space-y-2">
+ <Label>견적 유효기한 *</Label>
+ <Popover>
+ <PopoverTrigger asChild>
+ <Button
+ variant="outline"
+ className={cn(
+ "w-full justify-start text-left font-normal",
+ !validUntil && "text-muted-foreground"
+ )}
+ disabled={!canEdit}
+ >
+ <CalendarIcon className="mr-2 h-4 w-4" />
+ {validUntil ? formatDate(validUntil) : "유효기한을 선택하세요"}
+ </Button>
+ </PopoverTrigger>
+ <PopoverContent className="w-auto p-0">
+ <Calendar
+ mode="single"
+ selected={validUntil}
+ onSelect={setValidUntil}
+ initialFocus
+ />
+ </PopoverContent>
+ </Popover>
+ </div>
+
+ <div className="space-y-2">
+ <Label htmlFor="remark">비고</Label>
+ <Textarea
+ id="remark"
+ value={remark}
+ onChange={(e) => setRemark(e.target.value)}
+ placeholder="추가 설명이나 특이사항을 입력하세요"
+ rows={3}
+ disabled={!canEdit}
+ />
+ </div>
+
+ {/* 첨부파일 섹션 */}
+ <div className="space-y-2">
+ <Label>첨부파일</Label>
+ <div className="border-2 border-dashed border-gray-300 rounded-lg p-4">
+ <input
+ type="file"
+ id="file-upload"
+ multiple
+ onChange={handleFileSelect}
+ className="hidden"
+ disabled={!canEdit}
+ />
+ <div className="text-center">
+ <FileText className="mx-auto h-12 w-12 text-gray-400" />
+ <div className="mt-2">
+ <Label htmlFor="file-upload" className="cursor-pointer">
+ <span className="text-sm font-medium text-blue-600 hover:text-blue-500">
+ 파일을 선택하세요
+ </span>
+ </Label>
+ </div>
+ <p className="text-xs text-gray-500 mt-1">
+ PDF, DOC, DOCX, XLS, XLSX, PNG, JPG 등
+ </p>
+ </div>
+ </div>
+
+ {/* 첨부파일 목록 */}
+ {attachments.length > 0 && (
+ <div className="space-y-2">
+ <Label>첨부된 파일</Label>
+ <div className="space-y-2">
+ {attachments.map((attachment, index) => (
+ <div key={index} className="flex items-center justify-between p-2 bg-gray-50 rounded">
+ <div className="flex items-center gap-2">
+ <FileIcon className="h-4 w-4 text-gray-500" />
+ <span className="text-sm">{attachment.fileName}</span>
+ <span className="text-xs text-gray-500">
+ ({(attachment.fileSize / 1024).toFixed(1)} KB)
+ </span>
+ </div>
+ <div className="flex items-center gap-2">
+ {!attachment.isNew && (
+ <Button
+ variant="ghost"
+ size="sm"
+ onClick={() => window.open(attachment.filePath, '_blank')}
+ >
+ <Download className="h-4 w-4" />
+ </Button>
+ )}
+ {canEdit && (
+ <Button
+ variant="ghost"
+ size="sm"
+ onClick={() => removeAttachment(index)}
+ >
+ <X className="h-4 w-4" />
+ </Button>
+ )}
+ </div>
+ </div>
+ ))}
+ </div>
+ </div>
+ )}
+ </div>
+
+ {/* 제출 버튼 */}
+ <div className="flex justify-end">
+ <Button
+ onClick={handleSubmit}
+ disabled={!canSubmit || isLoading || isUploadingFiles}
+ className="flex items-center gap-2"
+ >
+ {isLoading ? (
+ <>
+ <div className="animate-spin rounded-full h-4 w-4 border-b-2 border-white" />
+ 제출 중...
+ </>
+ ) : (
+ <>
+ <Send className="h-4 w-4" />
+ 견적서 제출
+ </>
+ )}
+ </Button>
+ </div>
+ </CardContent>
+ </Card>
+
+ {/* 현재 견적 정보 (읽기 전용) */}
+ {quotation.totalPrice && (
+ <Card>
+ <CardHeader>
+ <CardTitle>현재 견적 정보</CardTitle>
+ <CardDescription>
+ 저장된 견적 정보
+ </CardDescription>
+ </CardHeader>
+ <CardContent className="space-y-4">
+ <div className="grid grid-cols-1 md:grid-cols-3 gap-4">
+ <div className="space-y-2">
+ <div className="text-sm font-medium text-muted-foreground">총 가격</div>
+ <div className="text-lg font-semibold">
+ {parseFloat(quotation.totalPrice).toLocaleString()} {quotation.currency}
+ </div>
+ </div>
+ <div className="space-y-2">
+ <div className="text-sm font-medium text-muted-foreground">통화</div>
+ <div className="text-sm">{quotation.currency}</div>
+ </div>
+ <div className="space-y-2">
+ <div className="text-sm font-medium text-muted-foreground">유효기간</div>
+ <div className="text-sm">
+ {quotation.validUntil ? formatDate(quotation.validUntil) : "N/A"}
+ </div>
+ </div>
+ </div>
+ {quotation.remark && (
+ <div className="space-y-2">
+ <div className="text-sm font-medium text-muted-foreground">비고</div>
+ <div className="text-sm p-3 bg-muted rounded-md">{quotation.remark}</div>
+ </div>
+ )}
+ </CardContent>
+ </Card>
+ )}
+ </div>
+
+ {/* 견적 히스토리 다이얼로그 */}
+ <QuotationHistoryDialog
+ open={historyDialogOpen}
+ onOpenChange={setHistoryDialogOpen}
+ quotationId={quotation.id}
+ />
+ </ScrollArea>
+ )
}
\ No newline at end of file diff --git a/lib/techsales-rfq/vendor-response/detail/quotation-tabs.tsx b/lib/techsales-rfq/vendor-response/detail/quotation-tabs.tsx index 2e2f5d70..7af50b24 100644 --- a/lib/techsales-rfq/vendor-response/detail/quotation-tabs.tsx +++ b/lib/techsales-rfq/vendor-response/detail/quotation-tabs.tsx @@ -1,84 +1,84 @@ -"use client" - -import * as React from "react" -import { useRouter, useSearchParams } from "next/navigation" -import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs" -import { ProjectInfoTab } from "./project-info-tab" -import { QuotationResponseTab } from "./quotation-response-tab" -import { CommunicationTab } from "./communication-tab" - -interface QuotationData { - id: number - status: string - totalPrice: string | null - currency: string | null - validUntil: Date | null - remark: string | null - rfq: { - id: number - rfqCode: string | null - materialCode: string | null - dueDate: Date | null - status: string | null - remark: string | null - biddingProject?: { - id: number - pspid: string | null - projNm: string | null - sector: string | null - projMsrm: string | null - ptypeNm: string | null - } | null - createdByUser?: { - id: number - name: string | null - email: string | null - } | null - } | null - vendor: { - id: number - vendorName: string - vendorCode: string | null - } | null -} - -interface TechSalesQuotationTabsProps { - quotation: QuotationData - defaultTab?: string -} - -export function TechSalesQuotationTabs({ quotation, defaultTab = "project" }: TechSalesQuotationTabsProps) { - const router = useRouter() - const searchParams = useSearchParams() - const currentTab = searchParams?.get("tab") || defaultTab - - const handleTabChange = (value: string) => { - const params = new URLSearchParams(searchParams?.toString() || "") - params.set("tab", value) - router.push(`?${params.toString()}`, { scroll: false }) - } - - return ( - <Tabs value={currentTab} onValueChange={handleTabChange} className="h-full flex flex-col"> - <TabsList className="grid w-full grid-cols-3"> - <TabsTrigger value="project">프로젝트 및 RFQ 정보</TabsTrigger> - <TabsTrigger value="quotation">견적 응답</TabsTrigger> - <TabsTrigger value="communication">커뮤니케이션</TabsTrigger> - </TabsList> - - <div className="flex-1 mt-4 overflow-hidden"> - <TabsContent value="project" className="h-full m-0"> - <ProjectInfoTab quotation={quotation} /> - </TabsContent> - - <TabsContent value="quotation" className="h-full m-0"> - <QuotationResponseTab quotation={quotation} /> - </TabsContent> - - <TabsContent value="communication" className="h-full m-0"> - <CommunicationTab quotation={quotation} /> - </TabsContent> - </div> - </Tabs> - ) +"use client"
+
+import * as React from "react"
+import { useRouter, useSearchParams } from "next/navigation"
+import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
+import { ProjectInfoTab } from "./project-info-tab"
+import { QuotationResponseTab } from "./quotation-response-tab"
+import { CommunicationTab } from "./communication-tab"
+
+interface QuotationData {
+ id: number
+ status: string
+ totalPrice: string | null
+ currency: string | null
+ validUntil: Date | null
+ remark: string | null
+ rfq: {
+ id: number
+ rfqCode: string | null
+ materialCode: string | null
+ dueDate: Date | null
+ status: string | null
+ remark: string | null
+ biddingProject?: {
+ id: number
+ pspid: string | null
+ projNm: string | null
+ sector: string | null
+ projMsrm: string | null
+ ptypeNm: string | null
+ } | null
+ createdByUser?: {
+ id: number
+ name: string | null
+ email: string | null
+ } | null
+ } | null
+ vendor: {
+ id: number
+ vendorName: string
+ vendorCode: string | null
+ } | null
+}
+
+interface TechSalesQuotationTabsProps {
+ quotation: QuotationData
+ defaultTab?: string
+}
+
+export function TechSalesQuotationTabs({ quotation, defaultTab = "project" }: TechSalesQuotationTabsProps) {
+ const router = useRouter()
+ const searchParams = useSearchParams()
+ const currentTab = searchParams?.get("tab") || defaultTab
+
+ const handleTabChange = (value: string) => {
+ const params = new URLSearchParams(searchParams?.toString() || "")
+ params.set("tab", value)
+ router.push(`?${params.toString()}`, { scroll: false })
+ }
+
+ return (
+ <Tabs value={currentTab} onValueChange={handleTabChange} className="h-full flex flex-col">
+ <TabsList className="grid w-full grid-cols-3">
+ <TabsTrigger value="project">프로젝트 및 RFQ 정보</TabsTrigger>
+ <TabsTrigger value="quotation">견적 응답</TabsTrigger>
+ <TabsTrigger value="communication">커뮤니케이션</TabsTrigger>
+ </TabsList>
+
+ <div className="flex-1 mt-4 overflow-hidden">
+ <TabsContent value="project" className="h-full m-0">
+ <ProjectInfoTab quotation={quotation} />
+ </TabsContent>
+
+ <TabsContent value="quotation" className="h-full m-0">
+ <QuotationResponseTab quotation={quotation} />
+ </TabsContent>
+
+ <TabsContent value="communication" className="h-full m-0">
+ <CommunicationTab quotation={quotation} />
+ </TabsContent>
+ </div>
+ </Tabs>
+ )
}
\ No newline at end of file |
