summaryrefslogtreecommitdiff
path: root/lib/techsales-rfq/vendor-response/detail
diff options
context:
space:
mode:
Diffstat (limited to 'lib/techsales-rfq/vendor-response/detail')
-rw-r--r--lib/techsales-rfq/vendor-response/detail/communication-tab.tsx215
-rw-r--r--lib/techsales-rfq/vendor-response/detail/project-info-tab.tsx269
-rw-r--r--lib/techsales-rfq/vendor-response/detail/quotation-response-tab.tsx382
-rw-r--r--lib/techsales-rfq/vendor-response/detail/quotation-tabs.tsx118
4 files changed, 984 insertions, 0 deletions
diff --git a/lib/techsales-rfq/vendor-response/detail/communication-tab.tsx b/lib/techsales-rfq/vendor-response/detail/communication-tab.tsx
new file mode 100644
index 00000000..0332232c
--- /dev/null
+++ b/lib/techsales-rfq/vendor-response/detail/communication-tab.tsx
@@ -0,0 +1,215 @@
+"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 { Textarea } from "@/components/ui/textarea"
+import { Badge } from "@/components/ui/badge"
+import { ScrollArea } from "@/components/ui/scroll-area"
+import { Separator } from "@/components/ui/separator"
+import { Avatar, AvatarFallback } from "@/components/ui/avatar"
+import { Send, MessageCircle } from "lucide-react"
+import { formatDateTime } from "@/lib/utils"
+import { toast } from "sonner"
+
+interface CommunicationTabProps {
+ quotation: {
+ id: number
+ rfq: {
+ id: number
+ rfqCode: string | null
+ createdByUser?: {
+ id: number
+ name: string | null
+ email: string | null
+ } | null
+ } | null
+ vendor: {
+ vendorName: string
+ } | null
+ }
+}
+
+// 임시 코멘트 데이터 (실제로는 API에서 가져와야 함)
+const MOCK_COMMENTS = [
+ {
+ id: 1,
+ content: "안녕하세요. 해당 자재에 대한 견적 요청 드립니다. 납기일은 언제까지 가능한지 문의드립니다.",
+ createdAt: new Date("2024-01-15T09:00:00"),
+ author: {
+ name: "김구매",
+ email: "buyer@company.com",
+ role: "구매담당자"
+ }
+ },
+ {
+ id: 2,
+ content: "안녕하세요. 견적 요청 확인했습니다. 해당 자재의 경우 약 2주 정도의 제작 기간이 필요합니다. 상세한 견적은 내일까지 제출하겠습니다.",
+ createdAt: new Date("2024-01-15T14:30:00"),
+ author: {
+ name: "이벤더",
+ email: "vendor@supplier.com",
+ role: "벤더"
+ }
+ },
+ {
+ id: 3,
+ content: "감사합니다. 추가로 품질 인증서도 함께 제출 가능한지 확인 부탁드립니다.",
+ createdAt: new Date("2024-01-16T10:15:00"),
+ author: {
+ name: "김구매",
+ email: "buyer@company.com",
+ role: "구매담당자"
+ }
+ }
+]
+
+export function CommunicationTab({ quotation }: CommunicationTabProps) {
+ const [newComment, setNewComment] = useState("")
+ const [isLoading, setIsLoading] = useState(false)
+ const [comments, setComments] = useState(MOCK_COMMENTS)
+
+ const handleSendComment = async () => {
+ if (!newComment.trim()) {
+ toast.error("메시지를 입력해주세요.")
+ return
+ }
+
+ setIsLoading(true)
+ try {
+ // TODO: API 호출로 코멘트 전송
+ const newCommentData = {
+ id: comments.length + 1,
+ content: newComment,
+ createdAt: new Date(),
+ author: {
+ name: "현재사용자", // 실제로는 세션에서 가져와야 함
+ email: "current@user.com",
+ role: "벤더"
+ }
+ }
+
+ setComments([...comments, newCommentData])
+ setNewComment("")
+ toast.success("메시지가 전송되었습니다.")
+ } catch {
+ toast.error("메시지 전송 중 오류가 발생했습니다.")
+ } finally {
+ setIsLoading(false)
+ }
+ }
+
+ const getAuthorInitials = (name: string) => {
+ return name
+ .split(" ")
+ .map(word => word[0])
+ .join("")
+ .toUpperCase()
+ .slice(0, 2)
+ }
+
+ const getRoleBadgeVariant = (role: string) => {
+ return role === "구매담당자" ? "default" : "secondary"
+ }
+
+ return (
+ <div className="h-full flex flex-col">
+ {/* 헤더 */}
+ <Card className="mb-4">
+ <CardHeader>
+ <CardTitle className="flex items-center gap-2">
+ <MessageCircle className="h-5 w-5" />
+ 커뮤니케이션
+ </CardTitle>
+ <CardDescription>
+ RFQ {quotation.rfq?.rfqCode || "미할당"}에 대한 구매담당자와의 커뮤니케이션
+ </CardDescription>
+ </CardHeader>
+ <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 className="flex-1 flex flex-col min-h-0">
+ <ScrollArea className="flex-1 pr-4">
+ <div className="space-y-4">
+ {comments.length === 0 ? (
+ <div className="text-center py-8 text-muted-foreground">
+ <MessageCircle className="h-12 w-12 mx-auto mb-4 opacity-50" />
+ <p>아직 메시지가 없습니다.</p>
+ <p className="text-sm">첫 번째 메시지를 보내보세요.</p>
+ </div>
+ ) : (
+ comments.map((comment) => (
+ <div key={comment.id} className="flex gap-3">
+ <Avatar className="h-8 w-8 mt-1">
+ <AvatarFallback className="text-xs">
+ {getAuthorInitials(comment.author.name)}
+ </AvatarFallback>
+ </Avatar>
+ <div className="flex-1 space-y-2">
+ <div className="flex items-center gap-2">
+ <span className="font-medium text-sm">{comment.author.name}</span>
+ <Badge variant={getRoleBadgeVariant(comment.author.role)} className="text-xs">
+ {comment.author.role}
+ </Badge>
+ <span className="text-xs text-muted-foreground">
+ {formatDateTime(comment.createdAt)}
+ </span>
+ </div>
+ <div className="bg-muted p-3 rounded-lg text-sm">
+ {comment.content}
+ </div>
+ </div>
+ </div>
+ ))
+ )}
+ </div>
+ </ScrollArea>
+
+ <Separator className="my-4" />
+
+ {/* 새 메시지 입력 */}
+ <div className="space-y-3">
+ <Textarea
+ placeholder="메시지를 입력하세요..."
+ value={newComment}
+ onChange={(e) => setNewComment(e.target.value)}
+ rows={3}
+ className="resize-none"
+ onKeyDown={(e) => {
+ if (e.key === "Enter" && (e.ctrlKey || e.metaKey)) {
+ e.preventDefault()
+ handleSendComment()
+ }
+ }}
+ />
+ <div className="flex justify-between items-center">
+ <div className="text-xs text-muted-foreground">
+ Ctrl + Enter로 빠른 전송
+ </div>
+ <Button
+ onClick={handleSendComment}
+ disabled={isLoading || !newComment.trim()}
+ size="sm"
+ >
+ <Send className="h-4 w-4 mr-2" />
+ 전송
+ </Button>
+ </div>
+ </div>
+ </CardContent>
+ </Card>
+ </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
new file mode 100644
index 00000000..7ba3320d
--- /dev/null
+++ b/lib/techsales-rfq/vendor-response/detail/project-info-tab.tsx
@@ -0,0 +1,269 @@
+"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 { formatDateToQuarter, formatDate } from "@/lib/utils"
+
+interface ProjectSnapshot {
+ pspid?: string
+ projNm?: string
+ projMsrm?: number
+ kunnr?: string
+ kunnrNm?: string
+ cls1?: string
+ cls1Nm?: string
+ ptype?: string
+ ptypeNm?: string
+ estmPm?: string
+ scDt?: string
+ klDt?: string
+ lcDt?: string
+ dlDt?: string
+ dockNo?: string
+ dockNm?: string
+ projNo?: string
+ ownerNm?: string
+ pspUpdatedAt?: string | Date
+}
+
+interface SeriesSnapshot {
+ sersNo?: string
+ klDt?: string
+}
+
+interface ProjectInfoTabProps {
+ quotation: {
+ id: number
+ rfq: {
+ id: number
+ rfqCode: string | null
+ materialCode: string | null
+ dueDate: Date | null
+ status: string | null
+ remark: string | null
+ projectSnapshot?: ProjectSnapshot | null
+ seriesSnapshot?: SeriesSnapshot[] | null
+ item?: {
+ id: number
+ itemCode: string | null
+ itemName: string | null
+ } | null
+ biddingProject?: {
+ id: number
+ pspid: string | null
+ projNm: 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
+ const projectSnapshot = rfq?.projectSnapshot
+ const seriesSnapshot = rfq?.seriesSnapshot
+
+ 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.item?.itemName || "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>
+ </CardContent>
+ </Card>
+ )}
+
+ {/* 프로젝트 스냅샷 정보 */}
+ {projectSnapshot && (
+ <Card>
+ <CardHeader>
+ <CardTitle>프로젝트 스냅샷</CardTitle>
+ <CardDescription>
+ RFQ 생성 시점의 프로젝트 상세 정보
+ </CardDescription>
+ </CardHeader>
+ <CardContent className="space-y-4">
+ <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
+ {projectSnapshot.projNo && (
+ <div className="space-y-2">
+ <div className="text-sm font-medium text-muted-foreground">공사번호</div>
+ <div className="text-sm">{projectSnapshot.projNo}</div>
+ </div>
+ )}
+ {projectSnapshot.projNm && (
+ <div className="space-y-2">
+ <div className="text-sm font-medium text-muted-foreground">공사명</div>
+ <div className="text-sm">{projectSnapshot.projNm}</div>
+ </div>
+ )}
+ {projectSnapshot.estmPm && (
+ <div className="space-y-2">
+ <div className="text-sm font-medium text-muted-foreground">견적 PM</div>
+ <div className="text-sm">{projectSnapshot.estmPm}</div>
+ </div>
+ )}
+ {projectSnapshot.kunnrNm && (
+ <div className="space-y-2">
+ <div className="text-sm font-medium text-muted-foreground">선주</div>
+ <div className="text-sm">{projectSnapshot.kunnrNm}</div>
+ </div>
+ )}
+ {projectSnapshot.cls1Nm && (
+ <div className="space-y-2">
+ <div className="text-sm font-medium text-muted-foreground">선급</div>
+ <div className="text-sm">{projectSnapshot.cls1Nm}</div>
+ </div>
+ )}
+ {projectSnapshot.projMsrm && (
+ <div className="space-y-2">
+ <div className="text-sm font-medium text-muted-foreground">척수</div>
+ <div className="text-sm">{projectSnapshot.projMsrm}</div>
+ </div>
+ )}
+ {projectSnapshot.ptypeNm && (
+ <div className="space-y-2">
+ <div className="text-sm font-medium text-muted-foreground">선종</div>
+ <div className="text-sm">{projectSnapshot.ptypeNm}</div>
+ </div>
+ )}
+ </div>
+ </CardContent>
+ </Card>
+ )}
+
+ {/* 시리즈 스냅샷 정보 */}
+ {seriesSnapshot && Array.isArray(seriesSnapshot) && seriesSnapshot.length > 0 && (
+ <Card>
+ <CardHeader>
+ <CardTitle>시리즈 정보 스냅샷</CardTitle>
+ <CardDescription>
+ 프로젝트의 시리즈별 K/L 일정 정보
+ </CardDescription>
+ </CardHeader>
+ <CardContent className="space-y-4">
+ {seriesSnapshot.map((series: SeriesSnapshot, index: number) => (
+ <div key={index} className="border rounded-lg p-4 space-y-3">
+ <div className="flex items-center gap-2">
+ <Badge variant="secondary">시리즈 {series.sersNo || index + 1}</Badge>
+ </div>
+ <div className="grid grid-cols-1 md:grid-cols-2 gap-4">
+ {series.klDt && (
+ <div className="space-y-1">
+ <div className="text-xs font-medium text-muted-foreground">K/L</div>
+ <div className="text-sm">{formatDateToQuarter(series.klDt)}</div>
+ </div>
+ )}
+ </div>
+ </div>
+ ))}
+ </CardContent>
+ </Card>
+ )}
+
+ {/* 정보가 없는 경우 */}
+ {!projectSnapshot && !seriesSnapshot && (
+ <Card>
+ <CardContent className="text-center py-8">
+ <div className="text-muted-foreground">
+ 추가 프로젝트 상세정보가 없습니다.
+ </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
new file mode 100644
index 00000000..3449dcb6
--- /dev/null
+++ b/lib/techsales-rfq/vendor-response/detail/quotation-response-tab.tsx
@@ -0,0 +1,382 @@
+"use client"
+
+import * as React from "react"
+import { useState } 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, Save, Send, AlertCircle } 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"
+
+interface QuotationResponseTabProps {
+ quotation: {
+ 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
+ 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 router = useRouter()
+
+ const rfq = quotation.rfq
+ const isDueDatePassed = rfq?.dueDate ? new Date(rfq.dueDate) < new Date() : false
+ const canSubmit = quotation.status === "Draft" && !isDueDatePassed
+ const canEdit = ["Draft", "Revised"].includes(quotation.status) && !isDueDatePassed
+
+ const handleSaveDraft = async () => {
+ setIsLoading(true)
+ try {
+ const { updateTechSalesVendorQuotation } = await import("@/lib/techsales-rfq/service")
+
+ const result = await updateTechSalesVendorQuotation({
+ id: quotation.id,
+ currency,
+ totalPrice,
+ validUntil: validUntil!,
+ remark,
+ updatedBy: 1 // TODO: 실제 사용자 ID로 변경
+ })
+
+ if (result.error) {
+ toast.error(result.error)
+ } else {
+ toast.success("임시 저장되었습니다.")
+ // 페이지 새로고침 대신 router.refresh() 사용
+ router.refresh()
+ }
+ } catch {
+ toast.error("저장 중 오류가 발생했습니다.")
+ } finally {
+ setIsLoading(false)
+ }
+ }
+
+ const handleSubmit = async () => {
+ if (!totalPrice || !currency || !validUntil) {
+ toast.error("모든 필수 항목을 입력해주세요.")
+ return
+ }
+
+ setIsLoading(true)
+ try {
+ const { submitTechSalesVendorQuotation } = await import("@/lib/techsales-rfq/service")
+
+ const result = await submitTechSalesVendorQuotation({
+ id: quotation.id,
+ currency,
+ totalPrice,
+ validUntil: validUntil!,
+ remark,
+ updatedBy: 1 // TODO: 실제 사용자 ID로 변경
+ })
+
+ if (result.error) {
+ toast.error(result.error)
+ } else {
+ toast.success("견적서가 제출되었습니다.")
+ // 페이지 새로고침 대신 router.refresh() 사용
+ router.refresh()
+ }
+ } 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>
+
+ {/* 액션 버튼 */}
+ {canEdit && (
+ <div className="flex gap-2 pt-4">
+ <Button
+ variant="outline"
+ onClick={handleSaveDraft}
+ disabled={isLoading}
+ className="flex-1"
+ >
+ <Save className="mr-2 h-4 w-4" />
+ 임시 저장
+ </Button>
+ {canSubmit && (
+ <Button
+ onClick={handleSubmit}
+ disabled={isLoading || !totalPrice || !currency || !validUntil}
+ className="flex-1"
+ >
+ <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>
+ )
+} \ 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
new file mode 100644
index 00000000..a800dd95
--- /dev/null
+++ b/lib/techsales-rfq/vendor-response/detail/quotation-tabs.tsx
@@ -0,0 +1,118 @@
+"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 ProjectSnapshot {
+ scDt?: string
+ klDt?: string
+ lcDt?: string
+ dlDt?: string
+ dockNo?: string
+ dockNm?: string
+ projNo?: string
+ projNm?: string
+ ownerNm?: string
+ kunnrNm?: string
+ cls1Nm?: string
+ projMsrm?: number
+ ptypeNm?: string
+ sector?: string
+ estmPm?: string
+}
+
+// 시리즈 스냅샷 타입 정의
+interface SeriesSnapshot {
+ sersNo?: string
+ scDt?: string
+ klDt?: string
+ lcDt?: string
+ dlDt?: string
+ dockNo?: string
+ dockNm?: string
+}
+
+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
+ projectSnapshot?: ProjectSnapshot | null
+ seriesSnapshot?: SeriesSnapshot[] | null
+ item?: {
+ id: number
+ itemCode: string | null
+ itemName: string | null
+ } | null
+ biddingProject?: {
+ id: number
+ pspid: string | null
+ projNm: 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