summaryrefslogtreecommitdiff
path: root/lib/rfq-last/vendor-response/editor
diff options
context:
space:
mode:
Diffstat (limited to 'lib/rfq-last/vendor-response/editor')
-rw-r--r--lib/rfq-last/vendor-response/editor/attachments-upload.tsx466
-rw-r--r--lib/rfq-last/vendor-response/editor/commercial-terms-form.tsx713
-rw-r--r--lib/rfq-last/vendor-response/editor/quotation-items-table.tsx449
-rw-r--r--lib/rfq-last/vendor-response/editor/rfq-info-header.tsx213
-rw-r--r--lib/rfq-last/vendor-response/editor/vendor-response-editor.tsx477
5 files changed, 2318 insertions, 0 deletions
diff --git a/lib/rfq-last/vendor-response/editor/attachments-upload.tsx b/lib/rfq-last/vendor-response/editor/attachments-upload.tsx
new file mode 100644
index 00000000..a2967767
--- /dev/null
+++ b/lib/rfq-last/vendor-response/editor/attachments-upload.tsx
@@ -0,0 +1,466 @@
+"use client"
+
+import { useState, useRef } 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 { Alert, AlertDescription } from "@/components/ui/alert"
+import {
+ Table,
+ TableBody,
+ TableCell,
+ TableHead,
+ TableHeader,
+ TableRow,
+} from "@/components/ui/table"
+import {
+ Upload,
+ FileText,
+ File,
+ Trash2,
+ Download,
+ AlertCircle,
+ Paperclip,
+ FileCheck,
+ Calculator,
+ Wrench
+} from "lucide-react"
+import { formatBytes } from "@/lib/utils"
+import { cn } from "@/lib/utils"
+
+interface FileWithType extends File {
+ attachmentType?: "구매" | "설계"
+ description?: string
+}
+
+interface AttachmentsUploadProps {
+ attachments: FileWithType[]
+ onAttachmentsChange: (files: FileWithType[]) => void
+ existingAttachments?: any[]
+}
+
+const acceptedFileTypes = {
+ documents: ".pdf,.doc,.docx,.xls,.xlsx,.ppt,.pptx",
+ images: ".jpg,.jpeg,.png,.gif,.bmp",
+ compressed: ".zip,.rar,.7z",
+ all: ".pdf,.doc,.docx,.xls,.xlsx,.ppt,.pptx,.jpg,.jpeg,.png,.gif,.bmp,.zip,.rar,.7z"
+}
+
+export default function AttachmentsUpload({
+ attachments,
+ onAttachmentsChange,
+ existingAttachments = []
+}: AttachmentsUploadProps) {
+ const purchaseInputRef = useRef<HTMLInputElement>(null)
+ const designInputRef = useRef<HTMLInputElement>(null)
+ const [purchaseDragActive, setPurchaseDragActive] = useState(false)
+ const [designDragActive, setDesignDragActive] = useState(false)
+ const [uploadErrors, setUploadErrors] = useState<string[]>([])
+
+ // 파일 유효성 검사
+ const validateFile = (file: File): string | null => {
+ const maxSize = 1024 * 1024 * 1024 // 10MB
+ const allowedExtensions = acceptedFileTypes.all.split(',')
+ const fileExtension = `.${file.name.split('.').pop()?.toLowerCase()}`
+
+ if (file.size > maxSize) {
+ return `${file.name}: 파일 크기가 1GB를 초과합니다`
+ }
+
+ if (!allowedExtensions.includes(fileExtension)) {
+ return `${file.name}: 허용되지 않은 파일 형식입니다`
+ }
+
+ return null
+ }
+
+ // 파일 추가
+ const handleFileAdd = (files: FileList | null, type: "구매" | "설계") => {
+ if (!files) return
+
+ const newFiles: FileWithType[] = []
+ const errors: string[] = []
+
+ Array.from(files).forEach(file => {
+ const error = validateFile(file)
+ if (error) {
+ errors.push(error)
+ } else {
+ const fileWithType = Object.assign(file, {
+ attachmentType: type,
+ description: ""
+ })
+ newFiles.push(fileWithType)
+ }
+ })
+
+ if (errors.length > 0) {
+ setUploadErrors(errors)
+ setTimeout(() => setUploadErrors([]), 5000)
+ }
+
+ if (newFiles.length > 0) {
+ onAttachmentsChange([...attachments, ...newFiles])
+ }
+ }
+
+ // 구매 드래그 앤 드롭 핸들러
+ const handlePurchaseDrag = (e: React.DragEvent) => {
+ e.preventDefault()
+ e.stopPropagation()
+ if (e.type === "dragenter" || e.type === "dragover") {
+ setPurchaseDragActive(true)
+ } else if (e.type === "dragleave") {
+ setPurchaseDragActive(false)
+ }
+ }
+
+ const handlePurchaseDrop = (e: React.DragEvent) => {
+ e.preventDefault()
+ e.stopPropagation()
+ setPurchaseDragActive(false)
+
+ if (e.dataTransfer.files && e.dataTransfer.files[0]) {
+ handleFileAdd(e.dataTransfer.files, "구매")
+ }
+ }
+
+ // 설계 드래그 앤 드롭 핸들러
+ const handleDesignDrag = (e: React.DragEvent) => {
+ e.preventDefault()
+ e.stopPropagation()
+ if (e.type === "dragenter" || e.type === "dragover") {
+ setDesignDragActive(true)
+ } else if (e.type === "dragleave") {
+ setDesignDragActive(false)
+ }
+ }
+
+ const handleDesignDrop = (e: React.DragEvent) => {
+ e.preventDefault()
+ e.stopPropagation()
+ setDesignDragActive(false)
+
+ if (e.dataTransfer.files && e.dataTransfer.files[0]) {
+ handleFileAdd(e.dataTransfer.files, "설계")
+ }
+ }
+
+ // 파일 삭제
+ const handleFileRemove = (index: number) => {
+ const newFiles = attachments.filter((_, i) => i !== index)
+ onAttachmentsChange(newFiles)
+ }
+
+ // 파일 타입 변경
+ const handleTypeChange = (index: number, newType: "구매" | "설계") => {
+ const newFiles = [...attachments]
+ newFiles[index].attachmentType = newType
+ onAttachmentsChange(newFiles)
+ }
+
+ // 파일 아이콘 가져오기
+ const getFileIcon = (fileName: string) => {
+ const extension = fileName.split('.').pop()?.toLowerCase()
+ const imageExtensions = ['jpg', 'jpeg', 'png', 'gif', 'bmp']
+
+ if (imageExtensions.includes(extension || '')) {
+ return <File className="h-4 w-4 text-blue-500" />
+ }
+ return <FileText className="h-4 w-4 text-gray-500" />
+ }
+
+ // 구매/설계 문서 개수 계산
+ const purchaseCount = attachments.filter(f => f.attachmentType === "구매").length +
+ existingAttachments.filter(f => f.attachmentType === "구매").length
+ const designCount = attachments.filter(f => f.attachmentType === "설계").length +
+ existingAttachments.filter(f => f.attachmentType === "설계").length
+
+ return (
+ <div className="space-y-4">
+ {/* 필수 파일 안내 */}
+ <Alert>
+ <AlertCircle className="h-4 w-4" />
+ <AlertDescription>
+ <strong>문서 분류:</strong> 구매 문서(견적서, 상업조건 등)와 설계 문서(기술문서, 성적서, 인증서 등)를 구분하여 업로드하세요.
+ <br />
+ <strong>허용 파일:</strong> PDF, Word, Excel, PowerPoint, 이미지 파일, 압축 파일(ZIP, RAR, 7Z) (최대 1GB)
+ </AlertDescription>
+ </Alert>
+
+ {/* 업로드 오류 표시 */}
+ {uploadErrors.length > 0 && (
+ <Alert variant="destructive">
+ <AlertCircle className="h-4 w-4" />
+ <AlertDescription>
+ <ul className="list-disc list-inside">
+ {uploadErrors.map((error, index) => (
+ <li key={index}>{error}</li>
+ ))}
+ </ul>
+ </AlertDescription>
+ </Alert>
+ )}
+
+ {/* 두 개의 드래그존 */}
+ <div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
+ {/* 구매 문서 업로드 영역 */}
+ <Card>
+ <CardHeader>
+ <CardTitle className="flex items-center gap-2">
+ <Calculator className="h-5 w-5" />
+ 구매 문서
+ </CardTitle>
+ <CardDescription>
+ 견적서, 금액, 상업조건 관련 문서
+ </CardDescription>
+ </CardHeader>
+ <CardContent>
+ <div
+ className={cn(
+ "border-2 border-dashed rounded-lg p-6 text-center transition-colors",
+ purchaseDragActive ? "border-blue-500 bg-blue-50" : "border-gray-300",
+ "hover:border-blue-400 hover:bg-blue-50/50"
+ )}
+ onDragEnter={handlePurchaseDrag}
+ onDragLeave={handlePurchaseDrag}
+ onDragOver={handlePurchaseDrag}
+ onDrop={handlePurchaseDrop}
+ >
+ <Calculator className="mx-auto h-10 w-10 text-blue-500 mb-3" />
+ <p className="text-sm text-gray-600 mb-2">
+ 구매 문서를 드래그하여 업로드
+ </p>
+ <Button
+ type="button"
+ variant="outline"
+ size="sm"
+ onClick={() => purchaseInputRef.current?.click()}
+ className="border-blue-500 text-blue-600 hover:bg-blue-50"
+ >
+ <Paperclip className="h-4 w-4 mr-2" />
+ 구매 문서 선택
+ </Button>
+ <input
+ ref={purchaseInputRef}
+ type="file"
+ multiple
+ accept={acceptedFileTypes.all}
+ onChange={(e) => handleFileAdd(e.target.files, "구매")}
+ className="hidden"
+ />
+ {purchaseCount > 0 && (
+ <div className="mt-2">
+ <Badge variant="secondary">{purchaseCount}개 업로드됨</Badge>
+ </div>
+ )}
+ </div>
+ </CardContent>
+ </Card>
+
+ {/* 설계 문서 업로드 영역 */}
+ <Card>
+ <CardHeader>
+ <CardTitle className="flex items-center gap-2">
+ <Wrench className="h-5 w-5" />
+ 설계 문서
+ </CardTitle>
+ <CardDescription>
+ 기술문서, 성적서, 인증서, 도면 등
+ </CardDescription>
+ </CardHeader>
+ <CardContent>
+ <div
+ className={cn(
+ "border-2 border-dashed rounded-lg p-6 text-center transition-colors",
+ designDragActive ? "border-green-500 bg-green-50" : "border-gray-300",
+ "hover:border-green-400 hover:bg-green-50/50"
+ )}
+ onDragEnter={handleDesignDrag}
+ onDragLeave={handleDesignDrag}
+ onDragOver={handleDesignDrag}
+ onDrop={handleDesignDrop}
+ >
+ <Wrench className="mx-auto h-10 w-10 text-green-500 mb-3" />
+ <p className="text-sm text-gray-600 mb-2">
+ 설계 문서를 드래그하여 업로드
+ </p>
+ <Button
+ type="button"
+ variant="outline"
+ size="sm"
+ onClick={() => designInputRef.current?.click()}
+ className="border-green-500 text-green-600 hover:bg-green-50"
+ >
+ <Paperclip className="h-4 w-4 mr-2" />
+ 설계 문서 선택
+ </Button>
+ <input
+ ref={designInputRef}
+ type="file"
+ multiple
+ accept={acceptedFileTypes.all}
+ onChange={(e) => handleFileAdd(e.target.files, "설계")}
+ className="hidden"
+ />
+ {designCount > 0 && (
+ <div className="mt-2">
+ <Badge variant="secondary">{designCount}개 업로드됨</Badge>
+ </div>
+ )}
+ {/* <p className="text-xs text-gray-500 mt-2">
+ 최대 1GB, 여러 파일 선택 가능
+ </p> */}
+ </div>
+ </CardContent>
+ </Card>
+ </div>
+
+ {/* 첨부파일 목록 */}
+ {(attachments.length > 0 || existingAttachments.length > 0) && (
+ <Card>
+ <CardHeader>
+ <div className="flex items-center justify-between">
+ <CardTitle>첨부파일 목록</CardTitle>
+ <div className="flex items-center gap-2">
+ <Badge variant="outline" className="gap-1">
+ <Calculator className="h-3 w-3" />
+ 구매 {purchaseCount}
+ </Badge>
+ <Badge variant="outline" className="gap-1">
+ <Wrench className="h-3 w-3" />
+ 설계 {designCount}
+ </Badge>
+ <Badge variant="secondary">
+ 총 {attachments.length + existingAttachments.length}개
+ </Badge>
+ </div>
+ </div>
+ </CardHeader>
+ <CardContent>
+ <Table>
+ <TableHeader>
+ <TableRow>
+ <TableHead className="w-[50px]">유형</TableHead>
+ <TableHead>파일명</TableHead>
+ <TableHead className="w-[100px]">크기</TableHead>
+ <TableHead className="w-[120px]">문서 구분</TableHead>
+ <TableHead className="w-[100px]">상태</TableHead>
+ <TableHead className="w-[80px]">작업</TableHead>
+ </TableRow>
+ </TableHeader>
+ <TableBody>
+ {/* 기존 첨부파일 */}
+ {existingAttachments.map((file, index) => (
+ <TableRow key={`existing-${index}`}>
+ <TableCell>
+ {getFileIcon(file.originalFileName)}
+ </TableCell>
+ <TableCell>
+ <div>
+ <p className="font-medium">{file.originalFileName}</p>
+ {file.description && (
+ <p className="text-xs text-muted-foreground">
+ {file.description}
+ </p>
+ )}
+ </div>
+ </TableCell>
+ <TableCell className="text-sm text-muted-foreground">
+ {formatBytes(file.fileSize || 0)}
+ </TableCell>
+ <TableCell>
+ <Badge
+ variant={file.attachmentType === "구매" ? "default" : "secondary"}
+ className="gap-1"
+ >
+ {file.attachmentType === "구매" ?
+ <Calculator className="h-3 w-3" /> :
+ <Wrench className="h-3 w-3" />
+ }
+ {file.attachmentType}
+ </Badge>
+ </TableCell>
+ <TableCell>
+ <Badge variant="secondary">
+ <FileCheck className="h-3 w-3 mr-1" />
+ 기존
+ </Badge>
+ </TableCell>
+ <TableCell>
+ <Button
+ type="button"
+ variant="ghost"
+ size="sm"
+ onClick={() => window.open(file.filePath, '_blank')}
+ >
+ <Download className="h-4 w-4" />
+ </Button>
+ </TableCell>
+ </TableRow>
+ ))}
+
+ {/* 새로 추가된 파일 */}
+ {attachments.map((file, index) => (
+ <TableRow key={`new-${index}`}>
+ <TableCell>
+ {getFileIcon(file.name)}
+ </TableCell>
+ <TableCell>
+ <div>
+ <p className="font-medium">{file.name}</p>
+ </div>
+ </TableCell>
+ <TableCell className="text-sm text-muted-foreground">
+ {formatBytes(file.size)}
+ </TableCell>
+ <TableCell>
+ <div className="flex items-center gap-1">
+ <Button
+ type="button"
+ variant={file.attachmentType === "구매" ? "default" : "ghost"}
+ size="sm"
+ className="h-7 px-2 text-xs"
+ onClick={() => handleTypeChange(index, "구매")}
+ >
+ <Calculator className="h-3 w-3 mr-1" />
+ 구매
+ </Button>
+ <Button
+ type="button"
+ variant={file.attachmentType === "설계" ? "default" : "ghost"}
+ size="sm"
+ className="h-7 px-2 text-xs"
+ onClick={() => handleTypeChange(index, "설계")}
+ >
+ <Wrench className="h-3 w-3 mr-1" />
+ 설계
+ </Button>
+ </div>
+ </TableCell>
+ <TableCell>
+ <Badge variant="default">
+ <Upload className="h-3 w-3 mr-1" />
+ 신규
+ </Badge>
+ </TableCell>
+ <TableCell>
+ <Button
+ type="button"
+ variant="ghost"
+ size="sm"
+ onClick={() => handleFileRemove(index)}
+ >
+ <Trash2 className="h-4 w-4 text-red-500" />
+ </Button>
+ </TableCell>
+ </TableRow>
+ ))}
+ </TableBody>
+ </Table>
+ </CardContent>
+ </Card>
+ )}
+ </div>
+ )
+} \ No newline at end of file
diff --git a/lib/rfq-last/vendor-response/editor/commercial-terms-form.tsx b/lib/rfq-last/vendor-response/editor/commercial-terms-form.tsx
new file mode 100644
index 00000000..143d08f3
--- /dev/null
+++ b/lib/rfq-last/vendor-response/editor/commercial-terms-form.tsx
@@ -0,0 +1,713 @@
+"use client"
+
+import * as React from "react"
+import { useFormContext } from "react-hook-form"
+import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
+import { Label } from "@/components/ui/label"
+import { Input } from "@/components/ui/input"
+import { Textarea } from "@/components/ui/textarea"
+import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
+import { Checkbox } from "@/components/ui/checkbox"
+import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group"
+import { Alert, AlertDescription } from "@/components/ui/alert"
+import { InfoIcon, ChevronsUpDown, Check, CalendarIcon, Loader2 } from "lucide-react"
+import { format } from "date-fns"
+import { Calendar } from "@/components/ui/calendar"
+import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"
+import { Button } from "@/components/ui/button"
+import { cn } from "@/lib/utils"
+import { Badge } from "@/components/ui/badge"
+import {
+ Command,
+ CommandEmpty,
+ CommandGroup,
+ CommandInput,
+ CommandItem,
+ CommandList,
+} from "@/components/ui/command"
+import {
+ getIncotermsForSelection,
+ getPaymentTermsForSelection,
+ getPlaceOfShippingForSelection,
+ getPlaceOfDestinationForSelection
+} from "@/lib/procurement-select/service"
+import { toast } from "sonner"
+
+interface CommercialTermsFormProps {
+ rfqDetail: any
+ rfq: any
+}
+
+interface SelectOption {
+ id: number
+ code: string
+ description: string
+}
+
+export default function CommercialTermsForm({ rfqDetail, rfq }: CommercialTermsFormProps) {
+ const { register, setValue, watch, formState: { errors } } = useFormContext()
+
+ console.log(rfqDetail,"rfqDetail")
+
+ // RFQ 코드가 F로 시작하는지 확인
+ const isFrameContract = rfq?.rfqCode?.startsWith("F")
+
+ // Select 옵션들 상태
+ const [incoterms, setIncoterms] = React.useState<SelectOption[]>([])
+ const [paymentTerms, setPaymentTerms] = React.useState<SelectOption[]>([])
+ const [shippingPlaces, setShippingPlaces] = React.useState<SelectOption[]>([])
+ const [destinationPlaces, setDestinationPlaces] = React.useState<SelectOption[]>([])
+
+ // 로딩 상태
+ const [incotermsLoading, setIncotermsLoading] = React.useState(false)
+ const [paymentTermsLoading, setPaymentTermsLoading] = React.useState(false)
+ const [shippingLoading, setShippingLoading] = React.useState(false)
+ const [destinationLoading, setDestinationLoading] = React.useState(false)
+
+ // Popover 열림 상태
+ const [incotermsOpen, setIncotermsOpen] = React.useState(false)
+ const [paymentTermsOpen, setPaymentTermsOpen] = React.useState(false)
+ const [shippingOpen, setShippingOpen] = React.useState(false)
+ const [destinationOpen, setDestinationOpen] = React.useState(false)
+
+ const vendorCurrency = watch("vendorCurrency")
+ const vendorPaymentTermsCode = watch("vendorPaymentTermsCode")
+ const vendorIncotermsCode = watch("vendorIncotermsCode")
+ const vendorDeliveryDate = watch("vendorDeliveryDate")
+ const vendorContractDuration = watch("vendorContractDuration")
+ const vendorFirstYn = watch("vendorFirstYn")
+ const vendorSparepartYn = watch("vendorSparepartYn")
+ const vendorMaterialPriceRelatedYn = watch("vendorMaterialPriceRelatedYn")
+
+ // 구매자 제시 조건과 다른지 확인
+ const isDifferentCurrency = vendorCurrency !== rfqDetail.currency
+ const isDifferentPaymentTerms = vendorPaymentTermsCode !== rfqDetail.paymentTermsCode
+ const isDifferentIncoterms = vendorIncotermsCode !== rfqDetail.incotermsCode
+ const isDifferentDeliveryDate = !isFrameContract && vendorDeliveryDate?.toISOString() !== rfqDetail.deliveryDate
+ const isDifferentContractDuration = isFrameContract && vendorContractDuration !== rfqDetail.contractDuration
+
+ // 데이터 로드 함수들
+ const loadIncoterms = React.useCallback(async () => {
+ setIncotermsLoading(true)
+ try {
+ const data = await getIncotermsForSelection()
+ setIncoterms(data)
+ } catch (error) {
+ console.error("Failed to load incoterms:", error)
+ toast.error("Incoterms 목록을 불러오는데 실패했습니다.")
+ } finally {
+ setIncotermsLoading(false)
+ }
+ }, [])
+
+ const loadPaymentTerms = React.useCallback(async () => {
+ setPaymentTermsLoading(true)
+ try {
+ const data = await getPaymentTermsForSelection()
+ setPaymentTerms(data)
+ } catch (error) {
+ console.error("Failed to load payment terms:", error)
+ toast.error("결제조건 목록을 불러오는데 실패했습니다.")
+ } finally {
+ setPaymentTermsLoading(false)
+ }
+ }, [])
+
+ const loadShippingPlaces = React.useCallback(async () => {
+ setShippingLoading(true)
+ try {
+ const data = await getPlaceOfShippingForSelection()
+ setShippingPlaces(data)
+ } catch (error) {
+ console.error("Failed to load shipping places:", error)
+ toast.error("선적지 목록을 불러오는데 실패했습니다.")
+ } finally {
+ setShippingLoading(false)
+ }
+ }, [])
+
+ const loadDestinationPlaces = React.useCallback(async () => {
+ setDestinationLoading(true)
+ try {
+ const data = await getPlaceOfDestinationForSelection()
+ setDestinationPlaces(data)
+ } catch (error) {
+ console.error("Failed to load destination places:", error)
+ toast.error("도착지 목록을 불러오는데 실패했습니다.")
+ } finally {
+ setDestinationLoading(false)
+ }
+ }, [])
+
+ // 컴포넌트 마운트 시 데이터 로드
+ React.useEffect(() => {
+ loadIncoterms()
+ loadPaymentTerms()
+ loadShippingPlaces()
+ loadDestinationPlaces()
+ }, [loadIncoterms, loadPaymentTerms, loadShippingPlaces, loadDestinationPlaces])
+
+ // 선택된 옵션 찾기
+ const selectedIncoterm = incoterms.find(i => i.code === vendorIncotermsCode)
+ const selectedPaymentTerm = paymentTerms.find(p => p.code === vendorPaymentTermsCode)
+ const selectedShipping = shippingPlaces.find(s => s.code === watch("vendorPlaceOfShipping"))
+ const selectedDestination = destinationPlaces.find(d => d.code === watch("vendorPlaceOfDestination"))
+
+ return (
+ <div className="space-y-6">
+ {/* 기본 상업 조건 */}
+ <Card>
+ <CardHeader>
+ <CardTitle>기본 상업 조건</CardTitle>
+ <CardDescription>
+ 구매자가 제시한 조건과 다른 경우, 변경 사유를 반드시 입력해주세요.
+ </CardDescription>
+ </CardHeader>
+ <CardContent className="space-y-6">
+ {/* 통화 */}
+ <div className="grid grid-cols-2 gap-4">
+ <div className="space-y-2">
+ <Label>구매자 제시 통화</Label>
+ <Input value={rfqDetail.currency || '-'} disabled />
+ </div>
+ <div className="space-y-2">
+ <Label htmlFor="vendorCurrency">벤더 제안 통화</Label>
+ <Select
+ value={vendorCurrency}
+ onValueChange={(value) => setValue("vendorCurrency", value)}
+ >
+ <SelectTrigger>
+ <SelectValue placeholder="통화 선택" />
+ </SelectTrigger>
+ <SelectContent>
+ <SelectItem value="USD">USD</SelectItem>
+ <SelectItem value="KRW">KRW</SelectItem>
+ <SelectItem value="EUR">EUR</SelectItem>
+ <SelectItem value="JPY">JPY</SelectItem>
+ <SelectItem value="CNY">CNY</SelectItem>
+ </SelectContent>
+ </Select>
+ </div>
+ </div>
+ {isDifferentCurrency && (
+ <div className="space-y-2">
+ <Label htmlFor="currencyReason">통화 변경 사유 *</Label>
+ <Textarea
+ id="currencyReason"
+ {...register("currencyReason")}
+ placeholder="통화 변경 사유를 입력하세요"
+ className="min-h-[80px]"
+ />
+ </div>
+ )}
+
+ {/* 지불 조건 */}
+ <div className="grid grid-cols-2 gap-4">
+ <div className="space-y-2">
+ <Label>구매자 제시 지불조건</Label>
+ <Input value={rfqDetail.paymentTermsDescription || rfqDetail.paymentTermsCode || '-'} disabled />
+ </div>
+ <div className="space-y-2">
+ <Label htmlFor="vendorPaymentTermsCode">벤더 제안 지불조건</Label>
+ <Popover open={paymentTermsOpen} onOpenChange={setPaymentTermsOpen}>
+ <PopoverTrigger asChild>
+ <Button
+ variant="outline"
+ role="combobox"
+ aria-expanded={paymentTermsOpen}
+ className="w-full justify-between"
+ disabled={paymentTermsLoading}
+ >
+ {selectedPaymentTerm ? (
+ <span className="truncate">
+ {selectedPaymentTerm.code} - {selectedPaymentTerm.description}
+ </span>
+ ) : (
+ <span className="text-muted-foreground">
+ {paymentTermsLoading ? "로딩 중..." : "지불조건 선택"}
+ </span>
+ )}
+ <ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
+ </Button>
+ </PopoverTrigger>
+ <PopoverContent className="w-full p-0" align="start">
+ <Command>
+ <CommandInput placeholder="코드 또는 설명으로 검색..." />
+ <CommandList>
+ <CommandEmpty>검색 결과가 없습니다.</CommandEmpty>
+ <CommandGroup>
+ {paymentTerms.map((term) => (
+ <CommandItem
+ key={term.id}
+ value={`${term.code} ${term.description}`}
+ onSelect={() => {
+ setValue("vendorPaymentTermsCode", term.code)
+ setPaymentTermsOpen(false)
+ }}
+ >
+ <div className="flex items-center gap-2 w-full">
+ <span className="font-medium">{term.code}</span>
+ <span className="text-muted-foreground">-</span>
+ <span className="truncate">{term.description}</span>
+ <Check
+ className={cn(
+ "ml-auto h-4 w-4",
+ term.code === vendorPaymentTermsCode ? "opacity-100" : "opacity-0"
+ )}
+ />
+ </div>
+ </CommandItem>
+ ))}
+ </CommandGroup>
+ </CommandList>
+ </Command>
+ </PopoverContent>
+ </Popover>
+ </div>
+ </div>
+ {isDifferentPaymentTerms && (
+ <div className="space-y-2">
+ <Label htmlFor="paymentTermsReason">지불조건 변경 사유 *</Label>
+ <Textarea
+ id="paymentTermsReason"
+ {...register("paymentTermsReason")}
+ placeholder="지불조건 변경 사유를 입력하세요"
+ className="min-h-[80px]"
+ />
+ </div>
+ )}
+
+ {/* 인코텀즈 */}
+ <div className="grid grid-cols-2 gap-4">
+ <div className="space-y-2">
+ <Label>구매자 제시 인코텀즈</Label>
+ <Input value={`${rfqDetail.incotermsCode || ''} ${rfqDetail.incotermsDetail || ''}`.trim() || '-'} disabled />
+ </div>
+ <div className="space-y-2">
+ <Label htmlFor="vendorIncotermsCode">벤더 제안 인코텀즈</Label>
+ <Popover open={incotermsOpen} onOpenChange={setIncotermsOpen}>
+ <PopoverTrigger asChild>
+ <Button
+ variant="outline"
+ role="combobox"
+ aria-expanded={incotermsOpen}
+ className="w-full justify-between"
+ disabled={incotermsLoading}
+ >
+ {selectedIncoterm ? (
+ <span className="truncate">
+ {selectedIncoterm.code} - {selectedIncoterm.description}
+ </span>
+ ) : (
+ <span className="text-muted-foreground">
+ {incotermsLoading ? "로딩 중..." : "인코텀즈 선택"}
+ </span>
+ )}
+ <ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
+ </Button>
+ </PopoverTrigger>
+ <PopoverContent className="w-full p-0" align="start">
+ <Command>
+ <CommandInput placeholder="코드 또는 설명으로 검색..." />
+ <CommandList>
+ <CommandEmpty>검색 결과가 없습니다.</CommandEmpty>
+ <CommandGroup>
+ {incoterms.map((incoterm) => (
+ <CommandItem
+ key={incoterm.id}
+ value={`${incoterm.code} ${incoterm.description}`}
+ onSelect={() => {
+ setValue("vendorIncotermsCode", incoterm.code)
+ setIncotermsOpen(false)
+ }}
+ >
+ <div className="flex items-center gap-2 w-full">
+ <span className="font-medium">{incoterm.code}</span>
+ <span className="text-muted-foreground">-</span>
+ <span className="truncate">{incoterm.description}</span>
+ <Check
+ className={cn(
+ "ml-auto h-4 w-4",
+ incoterm.code === vendorIncotermsCode ? "opacity-100" : "opacity-0"
+ )}
+ />
+ </div>
+ </CommandItem>
+ ))}
+ </CommandGroup>
+ </CommandList>
+ </Command>
+ </PopoverContent>
+ </Popover>
+ </div>
+ </div>
+ {isDifferentIncoterms && (
+ <div className="space-y-2">
+ <Label htmlFor="incotermsReason">인코텀즈 변경 사유 *</Label>
+ <Textarea
+ id="incotermsReason"
+ {...register("incotermsReason")}
+ placeholder="인코텀즈 변경 사유를 입력하세요"
+ className="min-h-[80px]"
+ />
+ </div>
+ )}
+
+ {/* 납기일 또는 계약 기간 */}
+ {isFrameContract ? (
+ // 계약 기간 (F로 시작하는 경우)
+ <div className="grid grid-cols-2 gap-4">
+ <div className="space-y-2">
+ <Label>구매자 제시 계약기간</Label>
+ <Input value={rfqDetail.contractDuration || '-'} disabled />
+ </div>
+ <div className="space-y-2">
+ <Label htmlFor="vendorContractDuration">벤더 제안 계약기간</Label>
+ <Input
+ id="vendorContractDuration"
+ {...register("vendorContractDuration")}
+ placeholder="예: 12개월"
+ />
+ </div>
+ </div>
+ ) : (
+ // 납기일 (일반적인 경우)
+ <div className="grid grid-cols-2 gap-4">
+ <div className="space-y-2">
+ <Label>구매자 제시 납기일</Label>
+ <Input
+ value={rfqDetail.deliveryDate ? format(new Date(rfqDetail.deliveryDate), "yyyy-MM-dd") : '-'}
+ disabled
+ />
+ </div>
+ <div className="space-y-2">
+ <Label htmlFor="vendorDeliveryDate">벤더 제안 납기일</Label>
+ <Popover>
+ <PopoverTrigger asChild>
+ <Button
+ variant="outline"
+ className={cn(
+ "w-full justify-start text-left font-normal",
+ !vendorDeliveryDate && "text-muted-foreground"
+ )}
+ >
+ <CalendarIcon className="mr-2 h-4 w-4" />
+ {vendorDeliveryDate ? format(vendorDeliveryDate, "yyyy-MM-dd") : "날짜 선택"}
+ </Button>
+ </PopoverTrigger>
+ <PopoverContent className="w-auto p-0">
+ <Calendar
+ mode="single"
+ selected={vendorDeliveryDate}
+ onSelect={(date) => setValue("vendorDeliveryDate", date)}
+ initialFocus
+ />
+ </PopoverContent>
+ </Popover>
+ </div>
+ </div>
+ )}
+
+ {/* 납기일/계약기간 변경 사유 (공통) */}
+ {(isDifferentDeliveryDate || isDifferentContractDuration) && (
+ <div className="space-y-2">
+ <Label htmlFor="deliveryDateReason">
+ {isFrameContract ? "계약기간 변경 사유 *" : "납기일 변경 사유 *"}
+ </Label>
+ <Textarea
+ id="deliveryDateReason"
+ {...register("deliveryDateReason")}
+ placeholder={isFrameContract ? "계약기간 변경 사유를 입력하세요" : "납기일 변경 사유를 입력하세요"}
+ className="min-h-[80px]"
+ />
+ </div>
+ )}
+
+ {/* 기타 조건들 */}
+ <div className="grid grid-cols-2 gap-4">
+ <div className="space-y-2">
+ <Label htmlFor="vendorTaxCode">세금 코드</Label>
+ <Input
+ id="vendorTaxCode"
+ {...register("vendorTaxCode")}
+ placeholder="세금 코드 입력"
+ />
+ </div>
+ <div className="space-y-2">
+ <Label htmlFor="vendorPlaceOfShipping">선적지</Label>
+ <Popover open={shippingOpen} onOpenChange={setShippingOpen}>
+ <PopoverTrigger asChild>
+ <Button
+ variant="outline"
+ role="combobox"
+ aria-expanded={shippingOpen}
+ className="w-full justify-between"
+ disabled={shippingLoading}
+ >
+ {selectedShipping ? (
+ <span className="truncate">
+ {selectedShipping.code} - {selectedShipping.description}
+ </span>
+ ) : (
+ <span className="text-muted-foreground">
+ {shippingLoading ? "로딩 중..." : "선적지 선택"}
+ </span>
+ )}
+ <ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
+ </Button>
+ </PopoverTrigger>
+ <PopoverContent className="w-full p-0" align="start">
+ <Command>
+ <CommandInput placeholder="선적지 검색..." />
+ <CommandList>
+ <CommandEmpty>검색 결과가 없습니다.</CommandEmpty>
+ <CommandGroup>
+ {shippingPlaces.map((place) => (
+ <CommandItem
+ key={place.id}
+ value={`${place.code} ${place.description}`}
+ onSelect={() => {
+ setValue("vendorPlaceOfShipping", place.code)
+ setShippingOpen(false)
+ }}
+ >
+ <div className="flex items-center gap-2 w-full">
+ <span className="font-medium">{place.code}</span>
+ <span className="text-muted-foreground">-</span>
+ <span className="truncate">{place.description}</span>
+ <Check
+ className={cn(
+ "ml-auto h-4 w-4",
+ place.code === watch("vendorPlaceOfShipping") ? "opacity-100" : "opacity-0"
+ )}
+ />
+ </div>
+ </CommandItem>
+ ))}
+ </CommandGroup>
+ </CommandList>
+ </Command>
+ </PopoverContent>
+ </Popover>
+ </div>
+ <div className="space-y-2">
+ <Label htmlFor="vendorPlaceOfDestination">도착지</Label>
+ <Popover open={destinationOpen} onOpenChange={setDestinationOpen}>
+ <PopoverTrigger asChild>
+ <Button
+ variant="outline"
+ role="combobox"
+ aria-expanded={destinationOpen}
+ className="w-full justify-between"
+ disabled={destinationLoading}
+ >
+ {selectedDestination ? (
+ <span className="truncate">
+ {selectedDestination.code} - {selectedDestination.description}
+ </span>
+ ) : (
+ <span className="text-muted-foreground">
+ {destinationLoading ? "로딩 중..." : "도착지 선택"}
+ </span>
+ )}
+ <ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
+ </Button>
+ </PopoverTrigger>
+ <PopoverContent className="w-full p-0" align="start">
+ <Command>
+ <CommandInput placeholder="도착지 검색..." />
+ <CommandList>
+ <CommandEmpty>검색 결과가 없습니다.</CommandEmpty>
+ <CommandGroup>
+ {destinationPlaces.map((place) => (
+ <CommandItem
+ key={place.id}
+ value={`${place.code} ${place.description}`}
+ onSelect={() => {
+ setValue("vendorPlaceOfDestination", place.code)
+ setDestinationOpen(false)
+ }}
+ >
+ <div className="flex items-center gap-2 w-full">
+ <span className="font-medium">{place.code}</span>
+ <span className="text-muted-foreground">-</span>
+ <span className="truncate">{place.description}</span>
+ <Check
+ className={cn(
+ "ml-auto h-4 w-4",
+ place.code === watch("vendorPlaceOfDestination") ? "opacity-100" : "opacity-0"
+ )}
+ />
+ </div>
+ </CommandItem>
+ ))}
+ </CommandGroup>
+ </CommandList>
+ </Command>
+ </PopoverContent>
+ </Popover>
+ </div>
+ </div>
+ </CardContent>
+ </Card>
+
+ {/* 특수 조건 */}
+ <Card>
+ <CardHeader>
+ <CardTitle>특수 조건</CardTitle>
+ <CardDescription>
+ 구매자가 요청한 특수 조건에 대한 응답을 입력하세요.
+ </CardDescription>
+ </CardHeader>
+ <CardContent className="space-y-6">
+ {/* 초도품 관리 */}
+ {rfqDetail.firstYn && (
+ <div className="space-y-4">
+ <div className="flex items-center justify-between">
+ <Label>초도품 관리 요청</Label>
+ <Badge variant="secondary">요청됨</Badge>
+ </div>
+ {rfqDetail.firstDescription && (
+ <Alert>
+ <InfoIcon className="h-4 w-4" />
+ <AlertDescription>{rfqDetail.firstDescription}</AlertDescription>
+ </Alert>
+ )}
+ <div className="space-y-2">
+ <Label>초도품 관리 수용 여부</Label>
+ <RadioGroup
+ value={watch("vendorFirstAcceptance") || ""}
+ onValueChange={(value) => setValue("vendorFirstAcceptance", value)}
+ >
+ <div className="flex items-center space-x-2">
+ <RadioGroupItem value="수용" id="first-accept" />
+ <Label htmlFor="first-accept">수용</Label>
+ </div>
+ <div className="flex items-center space-x-2">
+ <RadioGroupItem value="부분수용" id="first-partial" />
+ <Label htmlFor="first-partial">부분수용</Label>
+ </div>
+ <div className="flex items-center space-x-2">
+ <RadioGroupItem value="거부" id="first-reject" />
+ <Label htmlFor="first-reject">거부</Label>
+ </div>
+ </RadioGroup>
+ </div>
+ <div className="space-y-2">
+ <Label htmlFor="vendorFirstDescription">초도품 관리 응답 상세</Label>
+ <Textarea
+ id="vendorFirstDescription"
+ {...register("vendorFirstDescription")}
+ placeholder="초도품 관리에 대한 상세 응답을 입력하세요"
+ className="min-h-[100px]"
+ />
+ </div>
+ </div>
+ )}
+
+ {/* Spare Part */}
+ {rfqDetail.sparepartYn && (
+ <div className="space-y-4">
+ <div className="flex items-center justify-between">
+ <Label>Spare Part 요청</Label>
+ <Badge variant="secondary">요청됨</Badge>
+ </div>
+ {rfqDetail.sparepartDescription && (
+ <Alert>
+ <InfoIcon className="h-4 w-4" />
+ <AlertDescription>{rfqDetail.sparepartDescription}</AlertDescription>
+ </Alert>
+ )}
+ <div className="space-y-2">
+ <Label>Spare Part 수용 여부</Label>
+ <RadioGroup
+ value={watch("vendorSparepartAcceptance") || ""}
+ onValueChange={(value) => setValue("vendorSparepartAcceptance", value)}
+ >
+ <div className="flex items-center space-x-2">
+ <RadioGroupItem value="수용" id="spare-accept" />
+ <Label htmlFor="spare-accept">수용</Label>
+ </div>
+ <div className="flex items-center space-x-2">
+ <RadioGroupItem value="부분수용" id="spare-partial" />
+ <Label htmlFor="spare-partial">부분수용</Label>
+ </div>
+ <div className="flex items-center space-x-2">
+ <RadioGroupItem value="거부" id="spare-reject" />
+ <Label htmlFor="spare-reject">거부</Label>
+ </div>
+ </RadioGroup>
+ </div>
+ <div className="space-y-2">
+ <Label htmlFor="vendorSparepartDescription">Spare Part 응답 상세</Label>
+ <Textarea
+ id="vendorSparepartDescription"
+ {...register("vendorSparepartDescription")}
+ placeholder="Spare Part에 대한 상세 응답을 입력하세요"
+ className="min-h-[100px]"
+ />
+ </div>
+ </div>
+ )}
+
+ {/* 연동제 적용 */}
+ {rfqDetail.materialPriceRelatedYn && (
+ <div className="space-y-4">
+ <div className="flex items-center justify-between">
+ <Label>연동제 적용 요청</Label>
+ <Badge variant="secondary">요청됨</Badge>
+ </div>
+ <div className="flex items-center space-x-2">
+ <Checkbox
+ id="vendorMaterialPriceRelatedYn"
+ checked={vendorMaterialPriceRelatedYn}
+ onCheckedChange={(checked) => setValue("vendorMaterialPriceRelatedYn", checked)}
+ />
+ <Label htmlFor="vendorMaterialPriceRelatedYn">연동제 적용 동의</Label>
+ </div>
+ <div className="space-y-2">
+ <Label htmlFor="vendorMaterialPriceRelatedReason">연동제 관련 의견</Label>
+ <Textarea
+ id="vendorMaterialPriceRelatedReason"
+ {...register("vendorMaterialPriceRelatedReason")}
+ placeholder="연동제 적용에 대한 의견을 입력하세요"
+ className="min-h-[100px]"
+ />
+ </div>
+ </div>
+ )}
+ </CardContent>
+ </Card>
+
+ {/* 추가 의견 */}
+ <Card>
+ <CardHeader>
+ <CardTitle>추가 의견</CardTitle>
+ <CardDescription>
+ 견적서에 대한 추가 의견을 입력하세요.
+ </CardDescription>
+ </CardHeader>
+ <CardContent className="space-y-4">
+ {/* <div className="space-y-2">
+ <Label htmlFor="technicalProposal">기술 제안서</Label>
+ <Textarea
+ id="technicalProposal"
+ {...register("technicalProposal")}
+ placeholder="기술적 제안사항을 입력하세요"
+ className="min-h-[120px]"
+ />
+ </div> */}
+ <div className="space-y-2">
+ {/* <Label htmlFor="generalRemark">일반 비고</Label> */}
+ <Textarea
+ id="generalRemark"
+ {...register("generalRemark")}
+ placeholder="추가 비고사항을 입력하세요"
+ className="min-h-[120px]"
+ />
+ </div>
+ </CardContent>
+ </Card>
+ </div>
+ )
+} \ No newline at end of file
diff --git a/lib/rfq-last/vendor-response/editor/quotation-items-table.tsx b/lib/rfq-last/vendor-response/editor/quotation-items-table.tsx
new file mode 100644
index 00000000..08928b4d
--- /dev/null
+++ b/lib/rfq-last/vendor-response/editor/quotation-items-table.tsx
@@ -0,0 +1,449 @@
+"use client"
+
+import { useFormContext, useFieldArray } from "react-hook-form"
+import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
+import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
+import { Input } from "@/components/ui/input"
+import { Textarea } from "@/components/ui/textarea"
+import { Checkbox } from "@/components/ui/checkbox"
+import { Button } from "@/components/ui/button"
+import { Calendar } from "@/components/ui/calendar"
+import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"
+import { Badge } from "@/components/ui/badge"
+import { Label } from "@/components/ui/label"
+import { CalendarIcon, Eye, Calculator, AlertCircle } from "lucide-react"
+import { format } from "date-fns"
+import { cn, formatCurrency } from "@/lib/utils"
+import { useState } from "react"
+import {
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogHeader,
+ DialogTitle,
+} from "@/components/ui/dialog"
+
+interface QuotationItemsTableProps {
+ prItems: any[]
+}
+
+export default function QuotationItemsTable({ prItems }: QuotationItemsTableProps) {
+ const { control, register, setValue, watch } = useFormContext()
+ const { fields } = useFieldArray({
+ control,
+ name: "quotationItems"
+ })
+
+ const [selectedItem, setSelectedItem] = useState<any>(null)
+ const [showDetail, setShowDetail] = useState(false)
+
+ const currency = watch("vendorCurrency") || "USD"
+ const quotationItems = watch("quotationItems")
+
+ // 단가 * 수량 계산
+ const calculateTotal = (index: number) => {
+ const item = quotationItems[index]
+ const prItem = prItems[index]
+ if (item && prItem) {
+ const total = (item.unitPrice || 0) * (prItem.quantity || 0)
+ setValue(`quotationItems.${index}.totalPrice`, total)
+ }
+ }
+
+ // 할인 적용
+ const applyDiscount = (index: number) => {
+ const item = quotationItems[index]
+ const prItem = prItems[index]
+ if (item && prItem && item.discountRate) {
+ const originalTotal = (item.unitPrice || 0) * (prItem.quantity || 0)
+ const discountAmount = originalTotal * (item.discountRate / 100)
+ const finalTotal = originalTotal - discountAmount
+ setValue(`quotationItems.${index}.totalPrice`, finalTotal)
+ }
+ }
+
+ const totalAmount = quotationItems?.reduce(
+ (sum: number, item: any) => sum + (item.totalPrice || 0), 0
+ ) || 0
+
+ // 상세 정보 다이얼로그
+ const ItemDetailDialog = ({ item, prItem, index }: any) => (
+ <Dialog open={showDetail} onOpenChange={setShowDetail}>
+ <DialogContent className="max-w-3xl max-h-[80vh] overflow-y-auto">
+ <DialogHeader>
+ <DialogTitle>견적 상세 정보</DialogTitle>
+ <DialogDescription>
+ {prItem.materialCode} - {prItem.materialDescription}
+ </DialogDescription>
+ </DialogHeader>
+
+ <div className="space-y-4">
+ {/* PR 아이템 정보 */}
+ <Card>
+ <CardHeader className="pb-3">
+ <CardTitle className="text-base">PR 아이템 정보</CardTitle>
+ </CardHeader>
+ <CardContent className="grid grid-cols-2 gap-4">
+ <div>
+ <Label className="text-xs text-muted-foreground">PR 번호</Label>
+ <p className="font-medium">{prItem.prNo}</p>
+ </div>
+ <div>
+ <Label className="text-xs text-muted-foreground">자재 코드</Label>
+ <p className="font-medium">{prItem.materialCode}</p>
+ </div>
+ <div>
+ <Label className="text-xs text-muted-foreground">수량</Label>
+ <p className="font-medium">{prItem.quantity} {prItem.uom}</p>
+ </div>
+ <div>
+ <Label className="text-xs text-muted-foreground">요청 납기일</Label>
+ <p className="font-medium">
+ {prItem.deliveryDate ? format(new Date(prItem.deliveryDate), "yyyy-MM-dd") : '-'}
+ </p>
+ </div>
+ {prItem.specNo && (
+ <div>
+ <Label className="text-xs text-muted-foreground">스펙 번호</Label>
+ <p className="font-medium">{prItem.specNo}</p>
+ </div>
+ )}
+ {prItem.trackingNo && (
+ <div>
+ <Label className="text-xs text-muted-foreground">추적 번호</Label>
+ <p className="font-medium">{prItem.trackingNo}</p>
+ </div>
+ )}
+ </CardContent>
+ </Card>
+
+ {/* 제조사 정보 */}
+ <Card>
+ <CardHeader className="pb-3">
+ <CardTitle className="text-base">제조사 정보</CardTitle>
+ </CardHeader>
+ <CardContent className="space-y-3">
+ <div className="grid grid-cols-2 gap-4">
+ <div className="space-y-2">
+ <Label htmlFor={`manufacturer-${index}`}>제조사</Label>
+ <Input
+ id={`manufacturer-${index}`}
+ {...register(`quotationItems.${index}.manufacturer`)}
+ placeholder="제조사 입력"
+ />
+ </div>
+ <div className="space-y-2">
+ <Label htmlFor={`manufacturerCountry-${index}`}>제조국</Label>
+ <Input
+ id={`manufacturerCountry-${index}`}
+ {...register(`quotationItems.${index}.manufacturerCountry`)}
+ placeholder="제조국 입력"
+ />
+ </div>
+ </div>
+ <div className="space-y-2">
+ <Label htmlFor={`modelNo-${index}`}>모델 번호</Label>
+ <Input
+ id={`modelNo-${index}`}
+ {...register(`quotationItems.${index}.modelNo`)}
+ placeholder="모델 번호 입력"
+ />
+ </div>
+ </CardContent>
+ </Card>
+
+ {/* 기술 준수 및 대안 */}
+ <Card>
+ <CardHeader className="pb-3">
+ <CardTitle className="text-base">기술 사양</CardTitle>
+ </CardHeader>
+ <CardContent className="space-y-3">
+ <div className="flex items-center space-x-2">
+ <Checkbox
+ id={`technicalCompliance-${index}`}
+ checked={watch(`quotationItems.${index}.technicalCompliance`)}
+ onCheckedChange={(checked) =>
+ setValue(`quotationItems.${index}.technicalCompliance`, checked)
+ }
+ />
+ <Label htmlFor={`technicalCompliance-${index}`}>
+ 기술 사양 준수
+ </Label>
+ </div>
+
+ {!watch(`quotationItems.${index}.technicalCompliance`) && (
+ <div className="space-y-2">
+ <Label htmlFor={`alternativeProposal-${index}`}>
+ 대안 제안 <span className="text-red-500">*</span>
+ </Label>
+ <Textarea
+ id={`alternativeProposal-${index}`}
+ {...register(`quotationItems.${index}.alternativeProposal`)}
+ placeholder="기술 사양을 준수하지 않는 경우 대안을 제시해주세요"
+ className="min-h-[100px]"
+ />
+ </div>
+ )}
+
+ <div className="space-y-2">
+ <Label htmlFor={`deviationReason-${index}`}>편차 사유</Label>
+ <Textarea
+ id={`deviationReason-${index}`}
+ {...register(`quotationItems.${index}.deviationReason`)}
+ placeholder="요구사항과 다른 부분이 있는 경우 사유를 입력하세요"
+ className="min-h-[80px]"
+ />
+ </div>
+ </CardContent>
+ </Card>
+
+ {/* 할인 정보 */}
+ <Card>
+ <CardHeader className="pb-3">
+ <CardTitle className="text-base">할인 정보</CardTitle>
+ </CardHeader>
+ <CardContent className="space-y-3">
+ <div className="grid grid-cols-2 gap-4">
+ <div className="space-y-2">
+ <Label htmlFor={`discountRate-${index}`}>할인율 (%)</Label>
+ <Input
+ id={`discountRate-${index}`}
+ type="number"
+ step="0.01"
+ {...register(`quotationItems.${index}.discountRate`, { valueAsNumber: true })}
+ onChange={(e) => {
+ setValue(`quotationItems.${index}.discountRate`, parseFloat(e.target.value))
+ applyDiscount(index)
+ }}
+ placeholder="0.00"
+ />
+ </div>
+ <div className="space-y-2">
+ <Label>할인 금액</Label>
+ <div className="h-10 px-3 py-2 border rounded-md bg-muted">
+ {formatCurrency(
+ (watch(`quotationItems.${index}.unitPrice`) || 0) *
+ (prItem.quantity || 0) *
+ ((watch(`quotationItems.${index}.discountRate`) || 0) / 100),
+ currency
+ )}
+ </div>
+ </div>
+ </div>
+ </CardContent>
+ </Card>
+
+ {/* 비고 */}
+ <Card>
+ <CardHeader className="pb-3">
+ <CardTitle className="text-base">비고</CardTitle>
+ </CardHeader>
+ <CardContent>
+ <Textarea
+ {...register(`quotationItems.${index}.itemRemark`)}
+ placeholder="아이템별 비고사항을 입력하세요"
+ className="min-h-[100px]"
+ />
+ </CardContent>
+ </Card>
+ </div>
+ </DialogContent>
+ </Dialog>
+ )
+
+ return (
+ <Card>
+ <CardHeader>
+ <div className="flex items-center justify-between">
+ <div>
+ <CardTitle>견적 품목</CardTitle>
+ <CardDescription>
+ 각 PR 아이템에 대한 견적 단가와 정보를 입력하세요
+ </CardDescription>
+ </div>
+ <div className="text-right">
+ <p className="text-sm text-muted-foreground">총 견적금액</p>
+ <p className="text-2xl font-bold text-primary">
+ {formatCurrency(totalAmount, currency)}
+ </p>
+ </div>
+ </div>
+ </CardHeader>
+ <CardContent>
+ <div className="overflow-x-auto">
+ <Table>
+ <TableHeader>
+ <TableRow>
+ <TableHead className="w-[50px]">No</TableHead>
+ <TableHead className="w-[100px]">PR No</TableHead>
+ <TableHead className="min-w-[150px]">자재코드</TableHead>
+ <TableHead className="min-w-[200px]">자재설명</TableHead>
+ <TableHead className="text-right w-[100px]">수량</TableHead>
+ <TableHead className="w-[150px]">단가</TableHead>
+ <TableHead className="text-right w-[150px]">총액</TableHead>
+ <TableHead className="w-[150px]">납기일</TableHead>
+ <TableHead className="w-[100px]">리드타임</TableHead>
+ <TableHead className="w-[80px]">작업</TableHead>
+ </TableRow>
+ </TableHeader>
+ <TableBody>
+ {fields.map((field, index) => {
+ const prItem = prItems[index]
+ const quotationItem = quotationItems[index]
+ const isMajor = prItem?.majorYn
+
+ return (
+ <TableRow key={field.id} className={isMajor ? "bg-yellow-50" : ""}>
+ <TableCell>
+ <div className="flex items-center gap-2">
+ {prItem?.rfqItem || index + 1}
+ {isMajor && (
+ <Badge variant="secondary" className="text-xs">
+ 주요
+ </Badge>
+ )}
+ </div>
+ </TableCell>
+ <TableCell className="font-mono text-xs">
+ {prItem?.prNo}
+ </TableCell>
+ <TableCell className="font-mono text-xs">
+ {prItem?.materialCode}
+ </TableCell>
+ <TableCell>
+ <div className="max-w-[200px]">
+ <p className="truncate text-sm" title={prItem?.materialDescription}>
+ {prItem?.materialDescription}
+ </p>
+ {prItem?.size && (
+ <p className="text-xs text-muted-foreground">
+ 사이즈: {prItem.size}
+ </p>
+ )}
+ </div>
+ </TableCell>
+ <TableCell className="text-right">
+ {prItem?.quantity} {prItem?.uom}
+ </TableCell>
+ <TableCell>
+ <div className="flex items-center gap-1">
+ <Input
+ type="number"
+ step="0.01"
+ {...register(`quotationItems.${index}.unitPrice`, { valueAsNumber: true })}
+ onChange={(e) => {
+ setValue(`quotationItems.${index}.unitPrice`, parseFloat(e.target.value))
+ calculateTotal(index)
+ }}
+ className="w-[120px]"
+ placeholder="0.00"
+ />
+ <span className="text-xs text-muted-foreground">
+ {currency}
+ </span>
+ </div>
+ </TableCell>
+ <TableCell className="text-right font-medium">
+ {formatCurrency(quotationItem?.totalPrice || 0, currency)}
+ </TableCell>
+ <TableCell>
+ <Popover>
+ <PopoverTrigger asChild>
+ <Button
+ variant="outline"
+ size="sm"
+ className={cn(
+ "w-[130px] justify-start text-left font-normal",
+ !quotationItem?.vendorDeliveryDate && "text-muted-foreground"
+ )}
+ >
+ <CalendarIcon className="mr-2 h-3 w-3" />
+ {quotationItem?.vendorDeliveryDate
+ ? format(quotationItem.vendorDeliveryDate, "yyyy-MM-dd")
+ : "선택"}
+ </Button>
+ </PopoverTrigger>
+ <PopoverContent className="w-auto p-0" align="start">
+ <Calendar
+ mode="single"
+ selected={quotationItem?.vendorDeliveryDate}
+ onSelect={(date) => setValue(`quotationItems.${index}.vendorDeliveryDate`, date)}
+ initialFocus
+ />
+ </PopoverContent>
+ </Popover>
+ {prItem?.deliveryDate && quotationItem?.vendorDeliveryDate &&
+ new Date(quotationItem.vendorDeliveryDate) > new Date(prItem.deliveryDate) && (
+ <div className="mt-1">
+ <Badge variant="destructive" className="text-xs">
+ 지연
+ </Badge>
+ </div>
+ )}
+ </TableCell>
+ <TableCell>
+ <Input
+ type="number"
+ {...register(`quotationItems.${index}.leadTime`, { valueAsNumber: true })}
+ className="w-[80px]"
+ placeholder="일"
+ />
+ </TableCell>
+ <TableCell>
+ <Button
+ type="button"
+ variant="ghost"
+ size="sm"
+ onClick={() => {
+ setSelectedItem({ item: quotationItem, prItem, index })
+ setShowDetail(true)
+ }}
+ >
+ <Eye className="h-4 w-4" />
+ </Button>
+ </TableCell>
+ </TableRow>
+ )
+ })}
+ </TableBody>
+ </Table>
+ </div>
+
+ {/* 총액 요약 */}
+ <div className="mt-4 flex justify-end">
+ <Card className="w-[400px]">
+ <CardContent className="pt-4">
+ <div className="space-y-2">
+ <div className="flex justify-between text-sm">
+ <span className="text-muted-foreground">소계</span>
+ <span>{formatCurrency(totalAmount, currency)}</span>
+ </div>
+ <div className="flex justify-between text-sm">
+ <span className="text-muted-foreground">통화</span>
+ <span>{currency}</span>
+ </div>
+ <div className="border-t pt-2">
+ <div className="flex justify-between">
+ <span className="font-semibold">총 견적금액</span>
+ <span className="text-xl font-bold text-primary">
+ {formatCurrency(totalAmount, currency)}
+ </span>
+ </div>
+ </div>
+ </div>
+ </CardContent>
+ </Card>
+ </div>
+ </CardContent>
+
+ {/* 상세 다이얼로그 */}
+ {selectedItem && (
+ <ItemDetailDialog
+ item={selectedItem.item}
+ prItem={selectedItem.prItem}
+ index={selectedItem.index}
+ />
+ )}
+ </Card>
+ )
+} \ No newline at end of file
diff --git a/lib/rfq-last/vendor-response/editor/rfq-info-header.tsx b/lib/rfq-last/vendor-response/editor/rfq-info-header.tsx
new file mode 100644
index 00000000..1078b20e
--- /dev/null
+++ b/lib/rfq-last/vendor-response/editor/rfq-info-header.tsx
@@ -0,0 +1,213 @@
+"use client"
+
+import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
+import { Badge } from "@/components/ui/badge"
+import { formatDate } from "@/lib/utils"
+import { Building2, Package, Calendar, FileText, User, Users, Ship, Award, Anchor, ArrowLeft } from "lucide-react"
+import { useRouter } from "next/navigation"
+import { Button } from "@/components/ui/button";
+
+interface RfqInfoHeaderProps {
+ rfq: any
+ rfqDetail: any
+ vendor: any
+}
+
+export default function RfqInfoHeader({ rfq, rfqDetail, vendor }: RfqInfoHeaderProps) {
+ const majorMaterial = rfq.rfqPrItems?.find(v => v.majorYn)
+ const router = useRouter()
+
+ const handleGoBack = () => {
+ router.push("/partners/rfq-last");
+ };
+
+ return (
+ <Card>
+ <CardHeader>
+ <div className="flex items-center justify-between">
+ <CardTitle className="text-2xl">
+ 견적서 작성
+ <Badge variant="outline" className="ml-3">
+ {rfq.rfqCode}
+ </Badge>
+ </CardTitle>
+ <Button
+ variant="ghost"
+ size="sm"
+ onClick={handleGoBack}
+ className="gap-2"
+ >
+ <ArrowLeft className="h-4 w-4" />
+ 목록으로
+ </Button>
+ </div>
+ </CardHeader>
+ <CardContent className="space-y-4">
+ {/* 기본 정보 섹션 */}
+ <div className="grid grid-cols-2 lg:grid-cols-4 gap-6">
+ {/* 프로젝트 정보 */}
+ <div className="space-y-1">
+ <div className="flex items-center gap-2 text-sm text-muted-foreground">
+ <Building2 className="h-4 w-4" />
+ <span>프로젝트</span>
+ </div>
+ <p className="font-medium">{rfq.project?.name || '-'}</p>
+ <p className="text-xs text-muted-foreground">{rfq.project?.code || '-'}</p>
+ </div>
+
+ {/* 패키지 정보 */}
+ <div className="space-y-1">
+ <div className="flex items-center gap-2 text-sm text-muted-foreground">
+ <Package className="h-4 w-4" />
+ <span>패키지</span>
+ </div>
+ <p className="font-medium">{rfq.packageName || '-'}</p>
+ <p className="text-xs text-muted-foreground">{rfq.packageNo || '-'}</p>
+ </div>
+
+ {/* 자재 그룹 */}
+ <div className="space-y-1">
+ <div className="flex items-center gap-2 text-sm text-muted-foreground">
+ <Package className="h-4 w-4" />
+ <span>자재 그룹</span>
+ </div>
+ <p className="font-medium">{majorMaterial?.materialCategory || '-'}</p>
+ <p className="text-xs text-muted-foreground">{majorMaterial?.materialDescription || '-'}</p>
+ </div>
+
+ {/* 마감일 */}
+ <div className="space-y-1">
+ <div className="flex items-center gap-2 text-sm text-muted-foreground">
+ <Calendar className="h-4 w-4" />
+ <span>마감일</span>
+ </div>
+ <p className="font-medium">
+ {rfq.dueDate ? formatDate(new Date(rfq.dueDate)) : '-'}
+ </p>
+ {rfq.dueDate && (
+ <p className="text-xs text-muted-foreground">
+ {(() => {
+ const now = new Date()
+ const dueDate = new Date(rfq.dueDate)
+ const diffTime = dueDate.getTime() - now.getTime()
+ const diffDays = Math.ceil(diffTime / (1000 * 60 * 60 * 24))
+
+ if (diffDays < 0) {
+ return <span className="text-red-600">마감일 초과</span>
+ } else if (diffDays === 0) {
+ return <span className="text-orange-600">오늘 마감</span>
+ } else if (diffDays === 1) {
+ return <span className="text-orange-600">내일 마감</span>
+ } else {
+ return `${diffDays}일 남음`
+ }
+ })()}
+ </p>
+ )}
+ </div>
+
+ {/* 구매담당자 */}
+ <div className="space-y-1">
+ <div className="flex items-center gap-2 text-sm text-muted-foreground">
+ <User className="h-4 w-4" />
+ <span>구매담당자</span>
+ </div>
+ <p className="font-medium">{rfq.picName || '-'}</p>
+ </div>
+
+ {/* 설계담당자 */}
+ <div className="space-y-1">
+ <div className="flex items-center gap-2 text-sm text-muted-foreground">
+ <User className="h-4 w-4" />
+ <span>설계담당자</span>
+ </div>
+ <p className="font-medium">{rfq.EngPicName || '-'}</p>
+ </div>
+
+ {/* RFQ 제목 및 타입 (F로 시작하는 경우만) */}
+ {rfq.rfqCode && rfq.rfqCode.startsWith("F") && (
+ <>
+ <div className="space-y-1 col-span-2">
+ <div className="flex items-center gap-2 text-sm text-muted-foreground">
+ <FileText className="h-4 w-4" />
+ <span>견적 제목</span>
+ </div>
+ <p className="font-medium">{rfq.rfqTitle || '-'}</p>
+ </div>
+ <div className="space-y-1">
+ <div className="flex items-center gap-2 text-sm text-muted-foreground">
+ <FileText className="h-4 w-4" />
+ <span>견적 종류</span>
+ </div>
+ <p className="font-medium">{rfq.rfqType || '-'}</p>
+ </div>
+ </>
+ )}
+ </div>
+
+ {/* 프로젝트 상세 정보 섹션 (별도 div) */}
+
+ <div className="pt-4 border-t">
+ <h3 className="text-sm font-semibold mb-3">프로젝트 상세 정보</h3>
+ <div className="grid grid-cols-2 lg:grid-cols-5 gap-4">
+ {/* 고객정보 */}
+ {(
+ <div className="space-y-1">
+ <div className="flex items-center gap-2 text-sm text-muted-foreground">
+ <Users className="h-4 w-4" />
+ <span>고객정보</span>
+ </div>
+ <p className="font-medium">{rfq.project?.OWN_NM || '-'}</p>
+ </div>
+ )}
+
+ {/* 선급 */}
+ {(
+ <div className="space-y-1">
+ <div className="flex items-center gap-2 text-sm text-muted-foreground">
+ <Award className="h-4 w-4" />
+ <span>선급</span>
+ </div>
+ <p className="font-medium">{rfq.project?.CLS_1 || '-'}</p>
+ </div>
+ )}
+
+ {/* 선종 */}
+ {(
+ <div className="space-y-1">
+ <div className="flex items-center gap-2 text-sm text-muted-foreground">
+ <Ship className="h-4 w-4" />
+ <span>선종</span>
+ </div>
+ <p className="font-medium">{rfq.project?.SKND || '-'}</p>
+ </div>
+ )}
+
+ {/* 척수 */}
+ {(
+ <div className="space-y-1">
+ <div className="flex items-center gap-2 text-sm text-muted-foreground">
+ <Anchor className="h-4 w-4" />
+ <span>척수</span>
+ </div>
+ <p className="font-medium">{rfq.project?.TOT_CNRT_CNT || '-'}척</p>
+ </div>
+ )}
+
+ {/* 계약일 */}
+ {(
+ <div className="space-y-1">
+ <div className="flex items-center gap-2 text-sm text-muted-foreground">
+ <Calendar className="h-4 w-4" />
+ <span>계약일</span>
+ </div>
+ <p className="font-medium">{rfq.project && rfq.project.CNRT_DT ? formatDate(new Date(rfq.project.CNRT_DT)) : '-'}</p>
+ </div>
+ )}
+ </div>
+ </div>
+
+ </CardContent>
+ </Card>
+ )
+} \ No newline at end of file
diff --git a/lib/rfq-last/vendor-response/editor/vendor-response-editor.tsx b/lib/rfq-last/vendor-response/editor/vendor-response-editor.tsx
new file mode 100644
index 00000000..c146e42b
--- /dev/null
+++ b/lib/rfq-last/vendor-response/editor/vendor-response-editor.tsx
@@ -0,0 +1,477 @@
+"use client"
+
+import { useState } from "react"
+import { useForm, FormProvider } from "react-hook-form"
+import { zodResolver } from "@hookform/resolvers/zod"
+import * as z from "zod"
+import { useRouter } from "next/navigation"
+import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
+import { Button } from "@/components/ui/button"
+import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
+import { Badge } from "@/components/ui/badge"
+import { toast } from "sonner"
+import RfqInfoHeader from "./rfq-info-header"
+import CommercialTermsForm from "./commercial-terms-form"
+import QuotationItemsTable from "./quotation-items-table"
+import AttachmentsUpload from "./attachments-upload"
+import { formatDate, formatCurrency } from "@/lib/utils"
+import { Shield, FileText, CheckCircle, XCircle, Clock, Download, Eye, Save, Send, AlertCircle, Upload, } from "lucide-react"
+import { Progress } from "@/components/ui/progress"
+import { Alert, AlertDescription } from "@/components/ui/alert"
+
+// 폼 스키마 정의
+const vendorResponseSchema = z.object({
+ // 상업 조건
+ vendorCurrency: z.string().optional(),
+ vendorPaymentTermsCode: z.string().optional(),
+ vendorIncotermsCode: z.string().optional(),
+ vendorIncotermsDetail: z.string().optional(),
+ vendorDeliveryDate: z.date().optional().nullable(),
+ vendorContractDuration: z.string().optional(),
+ vendorTaxCode: z.string().optional(),
+ vendorPlaceOfShipping: z.string().optional(),
+ vendorPlaceOfDestination: z.string().optional(),
+
+ // 초도품관리
+ vendorFirstYn: z.boolean().optional(),
+ vendorFirstDescription: z.string().optional(),
+ vendorFirstAcceptance: z.enum(["수용", "부분수용", "거부"]).optional().nullable(),
+
+ // Spare part
+ vendorSparepartYn: z.boolean().optional(),
+ vendorSparepartDescription: z.string().optional(),
+ vendorSparepartAcceptance: z.enum(["수용", "부분수용", "거부"]).optional().nullable(),
+
+ // 연동제
+ vendorMaterialPriceRelatedYn: z.boolean().optional(),
+ vendorMaterialPriceRelatedReason: z.string().optional(),
+
+ // 변경 사유
+ currencyReason: z.string().optional(),
+ paymentTermsReason: z.string().optional(),
+ deliveryDateReason: z.string().optional(),
+ incotermsReason: z.string().optional(),
+ taxReason: z.string().optional(),
+ shippingReason: z.string().optional(),
+
+ // 비고
+ generalRemark: z.string().optional(),
+ technicalProposal: z.string().optional(),
+
+ // 견적 아이템
+ quotationItems: z.array(z.object({
+ rfqPrItemId: z.number(),
+ unitPrice: z.number().min(0),
+ totalPrice: z.number().min(0),
+ vendorDeliveryDate: z.date().optional().nullable(),
+ leadTime: z.number().optional(),
+ manufacturer: z.string().optional(),
+ manufacturerCountry: z.string().optional(),
+ modelNo: z.string().optional(),
+ technicalCompliance: z.boolean(),
+ alternativeProposal: z.string().optional(),
+ discountRate: z.number().optional(),
+ itemRemark: z.string().optional(),
+ deviationReason: z.string().optional(),
+ }))
+})
+
+type VendorResponseFormData = z.infer<typeof vendorResponseSchema>
+
+interface VendorResponseEditorProps {
+ rfq: any
+ rfqDetail: any
+ prItems: any[]
+ vendor: any
+ existingResponse?: any
+ userId: number
+ basicContracts?: any[] // 추가
+}
+
+export default function VendorResponseEditor({
+ rfq,
+ rfqDetail,
+ prItems,
+ vendor,
+ existingResponse,
+ userId,
+ basicContracts = [] // 추가
+
+}: VendorResponseEditorProps) {
+ const router = useRouter()
+ const [loading, setLoading] = useState(false)
+ const [activeTab, setActiveTab] = useState("info")
+ const [attachments, setAttachments] = useState<File[]>([])
+ const [uploadProgress, setUploadProgress] = useState(0) // 추가
+
+
+ // Form 초기값 설정
+ const defaultValues: VendorResponseFormData = {
+ vendorCurrency: existingResponse?.vendorCurrency || rfqDetail.currency,
+ vendorPaymentTermsCode: existingResponse?.vendorPaymentTermsCode || rfqDetail.paymentTermsCode,
+ vendorIncotermsCode: existingResponse?.vendorIncotermsCode || rfqDetail.incotermsCode,
+ vendorIncotermsDetail: existingResponse?.vendorIncotermsDetail || rfqDetail.incotermsDetail,
+ vendorDeliveryDate: existingResponse?.vendorDeliveryDate ? new Date(existingResponse.vendorDeliveryDate) :
+ rfqDetail.deliveryDate ? new Date(rfqDetail.deliveryDate) : null,
+ vendorContractDuration: existingResponse?.vendorContractDuration || rfqDetail.contractDuration,
+ vendorTaxCode: existingResponse?.vendorTaxCode || rfqDetail.taxCode,
+ vendorPlaceOfShipping: existingResponse?.vendorPlaceOfShipping || rfqDetail.placeOfShipping,
+ vendorPlaceOfDestination: existingResponse?.vendorPlaceOfDestination || rfqDetail.placeOfDestination,
+
+ vendorFirstYn: existingResponse?.vendorFirstYn ?? rfqDetail.firstYn,
+ vendorFirstDescription: existingResponse?.vendorFirstDescription || "",
+ vendorFirstAcceptance: existingResponse?.vendorFirstAcceptance || null,
+
+ vendorSparepartYn: existingResponse?.vendorSparepartYn ?? rfqDetail.sparepartYn,
+ vendorSparepartDescription: existingResponse?.vendorSparepartDescription || "",
+ vendorSparepartAcceptance: existingResponse?.vendorSparepartAcceptance || null,
+
+ vendorMaterialPriceRelatedYn: existingResponse?.vendorMaterialPriceRelatedYn ?? rfqDetail.materialPriceRelatedYn,
+ vendorMaterialPriceRelatedReason: existingResponse?.vendorMaterialPriceRelatedReason || "",
+
+ currencyReason: existingResponse?.currencyReason || "",
+ paymentTermsReason: existingResponse?.paymentTermsReason || "",
+ deliveryDateReason: existingResponse?.deliveryDateReason || "",
+ incotermsReason: existingResponse?.incotermsReason || "",
+ taxReason: existingResponse?.taxReason || "",
+ shippingReason: existingResponse?.shippingReason || "",
+
+ generalRemark: existingResponse?.generalRemark || "",
+ technicalProposal: existingResponse?.technicalProposal || "",
+
+ quotationItems: prItems.map(item => {
+ const existingItem = existingResponse?.quotationItems?.find(
+ (q: any) => q.rfqPrItemId === item.id
+ )
+ return {
+ rfqPrItemId: item.id,
+ unitPrice: existingItem?.unitPrice || 0,
+ totalPrice: existingItem?.totalPrice || 0,
+ vendorDeliveryDate: existingItem?.vendorDeliveryDate ? new Date(existingItem.vendorDeliveryDate) : null,
+ leadTime: existingItem?.leadTime || undefined,
+ manufacturer: existingItem?.manufacturer || "",
+ manufacturerCountry: existingItem?.manufacturerCountry || "",
+ modelNo: existingItem?.modelNo || "",
+ technicalCompliance: existingItem?.technicalCompliance ?? true,
+ alternativeProposal: existingItem?.alternativeProposal || "",
+ discountRate: existingItem?.discountRate || undefined,
+ itemRemark: existingItem?.itemRemark || "",
+ deviationReason: existingItem?.deviationReason || "",
+ }
+ })
+ }
+
+ const methods = useForm<VendorResponseFormData>({
+ resolver: zodResolver(vendorResponseSchema),
+ defaultValues
+ })
+
+ const onSubmit = async (data: VendorResponseFormData, isSubmit: boolean = false) => {
+ setLoading(true)
+ setUploadProgress(0)
+
+ try {
+ const formData = new FormData()
+
+ // 기본 데이터 추가
+ formData.append('data', JSON.stringify({
+ ...data,
+ rfqsLastId: rfq.id,
+ rfqLastDetailsId: rfqDetail.id,
+ vendorId: vendor.id,
+ status: isSubmit ? "제출완료" : "작성중",
+ submittedAt: isSubmit ? new Date().toISOString() : null,
+ submittedBy: isSubmit ? userId : null,
+ totalAmount: data.quotationItems.reduce((sum, item) => sum + item.totalPrice, 0),
+ updatedBy: userId
+ }))
+
+ // 첨부파일 추가
+ attachments.forEach((file, index) => {
+ formData.append(`attachments`, file)
+ })
+
+ // const response = await fetch(`/api/partners/rfq-last/${rfq.id}/response`, {
+ // method: existingResponse ? 'PUT' : 'POST',
+ // body: formData
+ // })
+
+ // if (!response.ok) {
+ // throw new Error('응답 저장에 실패했습니다.')
+ // }
+
+ // XMLHttpRequest 사용하여 업로드 진행률 추적
+ const xhr = new XMLHttpRequest()
+
+ // Promise로 감싸서 async/await 사용 가능하게
+ const uploadPromise = new Promise((resolve, reject) => {
+ // 업로드 진행률 이벤트
+ xhr.upload.addEventListener('progress', (event) => {
+ if (event.lengthComputable) {
+ const percentComplete = Math.round((event.loaded / event.total) * 100)
+ setUploadProgress(percentComplete)
+ }
+ })
+
+ // 완료 이벤트
+ xhr.addEventListener('load', () => {
+ if (xhr.status >= 200 && xhr.status < 300) {
+ setUploadProgress(100)
+ resolve(JSON.parse(xhr.responseText))
+ } else {
+ reject(new Error('응답 저장에 실패했습니다.'))
+ }
+ })
+
+ // 에러 이벤트
+ xhr.addEventListener('error', () => {
+ reject(new Error('네트워크 오류가 발생했습니다.'))
+ })
+
+ // 요청 전송
+ xhr.open(existingResponse ? 'PUT' : 'POST', `/api/partners/rfq-last/${rfq.id}/response`)
+ xhr.send(formData)
+ })
+
+ await uploadPromise
+
+ toast.success(isSubmit ? "견적서가 제출되었습니다." : "견적서가 저장되었습니다.")
+ router.push('/partners/rfq-last')
+ router.refresh()
+ } catch (error) {
+ console.error('Error:', error)
+ toast.error("오류가 발생했습니다.")
+ } finally {
+ setLoading(false)
+ }
+ }
+
+ const totalAmount = methods.watch('quotationItems')?.reduce(
+ (sum, item) => sum + (item.totalPrice || 0), 0
+ ) || 0
+
+
+ const allContractsSigned = basicContracts.length === 0 ||
+ basicContracts.every(contract => contract.signedAt);
+
+ return (
+ <FormProvider {...methods}>
+ <form onSubmit={methods.handleSubmit((data) => onSubmit(data, false))}>
+ <div className="space-y-6">
+ {/* 헤더 정보 */}
+ <RfqInfoHeader rfq={rfq} rfqDetail={rfqDetail} vendor={vendor} />
+
+ {/* 견적 총액 표시 */}
+ {totalAmount > 0 && (
+ <Card>
+ <CardContent className="pt-6">
+ <div className="flex items-center justify-between">
+ <span className="text-lg font-medium">견적 총액</span>
+ <span className="text-2xl font-bold text-primary">
+ {formatCurrency(totalAmount, methods.watch('vendorCurrency') || 'USD')}
+ </span>
+ </div>
+ </CardContent>
+ </Card>
+ )}
+
+ {/* 탭 콘텐츠 */}
+ <Tabs value={activeTab} onValueChange={setActiveTab} className="w-full">
+ <TabsList className="grid w-full grid-cols-4">
+ <TabsTrigger value="info">기본계약</TabsTrigger>
+ <TabsTrigger value="terms">상업조건</TabsTrigger>
+ <TabsTrigger value="items">견적품목</TabsTrigger>
+ <TabsTrigger value="attachments">첨부파일</TabsTrigger>
+ </TabsList>
+
+ <TabsContent value="info" className="mt-6">
+ <Card>
+ <CardHeader>
+ <CardTitle>기본계약 정보</CardTitle>
+ <CardDescription>
+ 이 RFQ에 요청된 기본계약 목록 및 상태입니다
+ </CardDescription>
+ </CardHeader>
+ <CardContent>
+ {basicContracts.length > 0 ? (
+ <div className="space-y-4">
+ {/* 계약 목록 - 그리드 레이아웃 */}
+ <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-3">
+ {basicContracts.map((contract) => (
+ <div
+ key={contract.id}
+ className="p-3 border rounded-lg bg-card hover:bg-muted/50 transition-colors"
+ >
+ <div className="flex items-start gap-2">
+ <div className="p-1.5 bg-primary/10 rounded">
+ <Shield className="h-3.5 w-3.5 text-primary" />
+ </div>
+ <div className="flex-1 min-w-0">
+ <h4 className="font-medium text-sm truncate" title={contract.templateName}>
+ {contract.templateName}
+ </h4>
+ <Badge
+ variant={contract.signedAt ? "success" : "warning"}
+ className="text-xs mt-1.5"
+ >
+ {contract.signedAt ? (
+ <>
+ <CheckCircle className="h-3 w-3 mr-1" />
+ 서명완료
+ </>
+ ) : (
+ <>
+ <Clock className="h-3 w-3 mr-1" />
+ 서명대기
+ </>
+ )}
+ </Badge>
+ <p className="text-xs text-muted-foreground mt-1">
+ {contract.signedAt
+ ? `${formatDate(new Date(contract.signedAt))}`
+ : contract.deadline
+ ? `~${formatDate(new Date(contract.deadline))}`
+ : '마감일 없음'}
+ </p>
+ </div>
+ </div>
+ </div>
+ ))}
+ </div>
+
+ {/* 서명 상태 요약 및 액션 */}
+ {basicContracts.some(contract => !contract.signedAt) ? (
+ <div className="flex items-center justify-between p-3 bg-amber-50 dark:bg-amber-950/20 border border-amber-200 dark:border-amber-900 rounded-lg">
+ <div className="flex items-center gap-2">
+ <AlertCircle className="h-4 w-4 text-amber-600" />
+ <div>
+ <p className="text-sm font-medium">
+ 서명 대기: {basicContracts.filter(c => !c.signedAt).length}/{basicContracts.length}개
+ </p>
+ <p className="text-xs text-muted-foreground">
+ 견적서 제출 전 모든 계약서 서명 필요
+ </p>
+ </div>
+ </div>
+ <Button
+ type="button"
+ size="sm"
+ onClick={() => router.push(`/partners/basic-contract`)}
+ >
+ 서명하기
+ </Button>
+ </div>
+ ) : (
+ <Alert className="border-green-200 bg-green-50 dark:bg-green-950/20">
+ <CheckCircle className="h-4 w-4 text-green-600" />
+ <AlertDescription className="text-sm">
+ 모든 기본계약 서명 완료
+ </AlertDescription>
+ </Alert>
+ )}
+ </div>
+ ) : (
+ <div className="text-center py-8">
+ <FileText className="h-12 w-12 text-muted-foreground mx-auto mb-4" />
+ <p className="text-muted-foreground">
+ 이 RFQ에 요청된 기본계약이 없습니다
+ </p>
+ </div>
+ )}
+</CardContent>
+ </Card>
+ </TabsContent>
+
+ <TabsContent value="terms" className="mt-6">
+ <CommercialTermsForm rfqDetail={rfqDetail} />
+ </TabsContent>
+
+ <TabsContent value="items" className="mt-6">
+ <QuotationItemsTable prItems={prItems} />
+ </TabsContent>
+
+ <TabsContent value="attachments" className="mt-6">
+ <AttachmentsUpload
+ attachments={attachments}
+ onAttachmentsChange={setAttachments}
+ existingAttachments={existingResponse?.attachments}
+ />
+ </TabsContent>
+ </Tabs>
+
+ {/* 하단 액션 버튼 */}
+ {loading && uploadProgress > 0 && (
+ <Card>
+ <CardContent className="pt-6">
+ <div className="space-y-2">
+ <div className="flex items-center justify-between text-sm">
+ <span className="flex items-center gap-2">
+ <Upload className="h-4 w-4 animate-pulse" />
+ 파일 업로드 중...
+ </span>
+ <span className="font-medium">{uploadProgress}%</span>
+ </div>
+ <Progress value={uploadProgress} className="h-2" />
+ <p className="text-xs text-muted-foreground">
+ 대용량 파일 업로드 시 시간이 걸릴 수 있습니다. 창을 닫지 마세요.
+ </p>
+ </div>
+ </CardContent>
+ </Card>
+ )}
+ <div className="flex justify-end gap-3">
+ <Button
+ type="button"
+ variant="outline"
+ onClick={() => router.back()}
+ disabled={loading}
+ >
+ 취소
+ </Button>
+ <Button
+ type="submit"
+ variant="secondary"
+ disabled={loading}
+ >
+ {loading ? (
+ <>
+ <div className="h-4 w-4 mr-2 animate-spin rounded-full border-2 border-current border-t-transparent" />
+ 처리중...
+ </>
+ ) : (
+ <>
+ <Save className="h-4 w-4 mr-2" />
+ 임시저장
+ </>
+ )}
+ </Button>
+ <Button
+ type="button"
+ variant="default"
+ onClick={methods.handleSubmit((data) => onSubmit(data, true))}
+ disabled={loading || !allContractsSigned}
+ >
+ {!allContractsSigned ? (
+ <>
+ <AlertCircle className="h-4 w-4 mr-2" />
+ 기본계약 서명 필요
+ </>
+ ) : loading ? (
+ <>
+ <div className="h-4 w-4 mr-2 animate-spin rounded-full border-2 border-current border-t-transparent" />
+ 처리중...
+ </>
+ ) : (
+ <>
+ <Send className="h-4 w-4 mr-2" />
+ 견적서 제출
+ </>
+ )}
+ </Button>
+ </div>
+
+ </div>
+ </form>
+ </FormProvider>
+ )
+} \ No newline at end of file