summaryrefslogtreecommitdiff
path: root/lib/rfq-last/vendor-response
diff options
context:
space:
mode:
Diffstat (limited to 'lib/rfq-last/vendor-response')
-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
-rw-r--r--lib/rfq-last/vendor-response/participation-dialog.tsx230
-rw-r--r--lib/rfq-last/vendor-response/rfq-attachments-dialog.tsx407
-rw-r--r--lib/rfq-last/vendor-response/rfq-items-dialog.tsx354
-rw-r--r--lib/rfq-last/vendor-response/service.ts483
-rw-r--r--lib/rfq-last/vendor-response/validations.ts42
-rw-r--r--lib/rfq-last/vendor-response/vendor-quotations-table-columns.tsx514
-rw-r--r--lib/rfq-last/vendor-response/vendor-quotations-table.tsx171
12 files changed, 4519 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
diff --git a/lib/rfq-last/vendor-response/participation-dialog.tsx b/lib/rfq-last/vendor-response/participation-dialog.tsx
new file mode 100644
index 00000000..a7337ac2
--- /dev/null
+++ b/lib/rfq-last/vendor-response/participation-dialog.tsx
@@ -0,0 +1,230 @@
+// components/vendor/participation-dialog.tsx
+
+"use client"
+
+import * as React from "react"
+import { useRouter } from "next/navigation"
+import {
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogFooter,
+ DialogHeader,
+ DialogTitle,
+} from "@/components/ui/dialog"
+import { Button } from "@/components/ui/button"
+import { Textarea } from "@/components/ui/textarea"
+import { Label } from "@/components/ui/label"
+import {
+ AlertDialog,
+ AlertDialogAction,
+ AlertDialogCancel,
+ AlertDialogContent,
+ AlertDialogDescription,
+ AlertDialogFooter,
+ AlertDialogHeader,
+ AlertDialogTitle,
+} from "@/components/ui/alert-dialog"
+import { useToast } from "@/hooks/use-toast"
+import { updateParticipationStatus } from "@/lib/rfq-last/vendor-response/service"
+import { CheckCircle, XCircle } from "lucide-react"
+
+interface ParticipationDialogProps {
+ rfqId: number
+ rfqCode: string
+ rfqLastDetailsId: number
+ currentStatus?: string
+ onClose: () => void
+}
+
+export function ParticipationDialog({
+ rfqId,
+ rfqCode,
+ rfqLastDetailsId,
+ currentStatus,
+ onClose,
+}: ParticipationDialogProps) {
+ const router = useRouter()
+ const [showDeclineDialog, setShowDeclineDialog] = React.useState(false)
+ const [showConfirmDialog, setShowConfirmDialog] = React.useState(false)
+ const [declineReason, setDeclineReason] = React.useState("")
+ const [isLoading, setIsLoading] = React.useState(false)
+ const { toast } = useToast();
+
+ const handleParticipate = () => {
+ setShowConfirmDialog(true)
+ }
+
+ const handleDecline = () => {
+ setShowDeclineDialog(true)
+ }
+
+ const confirmParticipation = async () => {
+ setIsLoading(true)
+ try {
+ const result = await updateParticipationStatus({
+ rfqId,
+ rfqLastDetailsId,
+ participationStatus: "참여",
+ })
+
+ if (result.success) {
+ toast({
+ title: "참여 확정",
+ description: result.message,
+ })
+ // router.push(`/partners/rfq-last/${rfqId}`)
+ router.refresh()
+ } else {
+ toast({
+ title: "오류",
+ description: result.message,
+ variant: "destructive",
+ })
+ }
+ } catch (error) {
+ toast({
+ title: "오류",
+ description: "참여 처리 중 오류가 발생했습니다.",
+ variant: "destructive",
+ })
+ } finally {
+ setIsLoading(false)
+ onClose()
+ }
+ }
+
+ const confirmDecline = async () => {
+ if (!declineReason.trim()) {
+ toast({
+ title: "불참 사유 필요",
+ description: "불참 사유를 입력해주세요.",
+ variant: "destructive",
+ })
+ return
+ }
+
+ setIsLoading(true)
+ try {
+ const result = await updateParticipationStatus({
+ rfqId,
+ rfqLastDetailsId,
+ participationStatus: "불참",
+ nonParticipationReason: declineReason,
+ })
+
+ if (result.success) {
+ toast({
+ title: "불참 처리 완료",
+ description: result.message,
+ })
+ router.refresh()
+ } else {
+ toast({
+ title: "오류",
+ description: result.message,
+ variant: "destructive",
+ })
+ }
+ } catch (error) {
+ toast({
+ title: "오류",
+ description: "불참 처리 중 오류가 발생했습니다.",
+ variant: "destructive",
+ })
+ } finally {
+ setIsLoading(false)
+ onClose()
+ }
+ }
+
+ return (
+ <>
+ {/* 메인 다이얼로그 */}
+ <AlertDialog open={true} onOpenChange={onClose}>
+ <AlertDialogContent>
+ <AlertDialogHeader>
+ <AlertDialogTitle>견적 참여 여부 결정</AlertDialogTitle>
+ <AlertDialogDescription>
+ {rfqCode} 견적 요청에 참여하시겠습니까?
+ </AlertDialogDescription>
+ </AlertDialogHeader>
+ <AlertDialogFooter>
+ <AlertDialogCancel>취소</AlertDialogCancel>
+ <Button
+ variant="outline"
+ onClick={handleDecline}
+ disabled={isLoading}
+ >
+ <XCircle className="mr-2 h-4 w-4" />
+ 불참
+ </Button>
+ <Button
+ onClick={handleParticipate}
+ disabled={isLoading}
+ >
+ <CheckCircle className="mr-2 h-4 w-4" />
+ 참여
+ </Button>
+ </AlertDialogFooter>
+ </AlertDialogContent>
+ </AlertDialog>
+
+ {/* 참여 확인 다이얼로그 */}
+ <AlertDialog open={showConfirmDialog} onOpenChange={setShowConfirmDialog}>
+ <AlertDialogContent>
+ <AlertDialogHeader>
+ <AlertDialogTitle>견적 참여 확정</AlertDialogTitle>
+ <AlertDialogDescription>
+ 견적 참여를 확정하시면 견적서 작성 페이지로 이동합니다.
+ </AlertDialogDescription>
+ </AlertDialogHeader>
+ <AlertDialogFooter>
+ <AlertDialogCancel>취소</AlertDialogCancel>
+ <AlertDialogAction onClick={confirmParticipation}>
+ 확정
+ </AlertDialogAction>
+ </AlertDialogFooter>
+ </AlertDialogContent>
+ </AlertDialog>
+
+ {/* 불참 사유 입력 다이얼로그 */}
+ <Dialog open={showDeclineDialog} onOpenChange={setShowDeclineDialog}>
+ <DialogContent>
+ <DialogHeader>
+ <DialogTitle>견적 불참</DialogTitle>
+ <DialogDescription>
+ 불참 사유를 입력해주세요.
+ </DialogDescription>
+ </DialogHeader>
+ <div className="grid gap-4 py-4">
+ <div className="grid gap-2">
+ <Label htmlFor="reason">불참 사유</Label>
+ <Textarea
+ id="reason"
+ placeholder="불참 사유를 입력하세요..."
+ value={declineReason}
+ onChange={(e) => setDeclineReason(e.target.value)}
+ rows={4}
+ />
+ </div>
+ </div>
+ <DialogFooter>
+ <Button
+ variant="outline"
+ onClick={() => setShowDeclineDialog(false)}
+ >
+ 취소
+ </Button>
+ <Button
+ onClick={confirmDecline}
+ disabled={isLoading || !declineReason.trim()}
+ >
+ 불참 확정
+ </Button>
+ </DialogFooter>
+ </DialogContent>
+ </Dialog>
+ </>
+ )
+} \ No newline at end of file
diff --git a/lib/rfq-last/vendor-response/rfq-attachments-dialog.tsx b/lib/rfq-last/vendor-response/rfq-attachments-dialog.tsx
new file mode 100644
index 00000000..cfe24d73
--- /dev/null
+++ b/lib/rfq-last/vendor-response/rfq-attachments-dialog.tsx
@@ -0,0 +1,407 @@
+"use client"
+
+import * as React from "react"
+import { format } from "date-fns"
+import { Download, FileText, Eye, ExternalLink, Loader2 } from "lucide-react"
+import {
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogHeader,
+ DialogTitle,
+} from "@/components/ui/dialog"
+import {
+ Table,
+ TableBody,
+ TableCell,
+ TableHead,
+ TableHeader,
+ TableRow,
+} from "@/components/ui/table"
+import { Button } from "@/components/ui/button"
+import { Badge } from "@/components/ui/badge"
+import { ScrollArea } from "@/components/ui/scroll-area"
+import { Skeleton } from "@/components/ui/skeleton"
+import { toast } from "sonner"
+import { RfqsLastView } from "@/db/schema"
+import { getRfqAttachmentsAction } from "../service"
+import { downloadFile, quickPreview, smartFileAction, formatFileSize, getFileInfo } from "@/lib/file-download"
+
+// 첨부파일 타입
+interface RfqAttachment {
+ attachmentId: number
+ attachmentType: string
+ serialNo: string
+ description: string | null
+ currentRevision: string
+ fileName: string
+ originalFileName: string
+ filePath: string
+ fileSize: number | null
+ fileType: string | null
+ createdByName: string | null
+ createdAt: Date | null
+ updatedAt: Date | null
+ revisionComment?: string | null
+}
+
+interface RfqAttachmentsDialogProps {
+ isOpen: boolean
+ onClose: () => void
+ rfqData: RfqsLastView
+}
+
+export function RfqAttachmentsDialog({ isOpen, onClose, rfqData }: RfqAttachmentsDialogProps) {
+ const [attachments, setAttachments] = React.useState<RfqAttachment[]>([])
+ const [isLoading, setIsLoading] = React.useState(false)
+ const [downloadingFiles, setDownloadingFiles] = React.useState<Set<number>>(new Set())
+ const [isDownloadingAll, setIsDownloadingAll] = React.useState(false);
+
+
+ const handleDownloadAll = async () => {
+ setIsDownloadingAll(true);
+ try {
+
+ const rfqId = rfqData.id
+
+ const attachments = await getRfqAttachmentsAction(rfqId);
+
+ if (!attachments.success || attachments.data.length === 0) {
+ toast.error(result.error || "다운로드할 파일이 없습니다");
+ }
+
+
+ // 2. ZIP 파일 생성 (서버에서)
+ const response = await fetch(`/api/rfq/attachments/download-all`, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({
+ rfqId,
+ files: attachments.data.map(a => ({
+ path: a.filePath,
+ name: a.originalFileName
+ }))
+ })
+ });
+
+ if (!response.ok) throw new Error('ZIP 생성 실패');
+
+ // 3. ZIP 다운로드
+ const blob = await response.blob();
+ const url = URL.createObjectURL(blob);
+ const link = document.createElement('a');
+
+ // RFQ 코드를 포함한 파일명
+ const date = new Date().toISOString().slice(0, 10);
+ link.download = `RFQ_${rfqId}_attachments_${date}.zip`;
+ link.href = url;
+
+ document.body.appendChild(link);
+ link.click();
+ document.body.removeChild(link);
+
+ } finally {
+ setIsDownloadingAll(false);
+ }
+ };
+
+ // 첨부파일 목록 로드
+ React.useEffect(() => {
+ if (!isOpen || !rfqData.id) return
+
+ const loadAttachments = async () => {
+ setIsLoading(true)
+ try {
+ const result = await getRfqAttachmentsAction(rfqData.id)
+
+ if (result.success) {
+ setAttachments(result.data)
+ } else {
+ toast.error(result.error || "첨부파일을 불러오는데 실패했습니다")
+ setAttachments([])
+ }
+ } catch (error) {
+ console.error("첨부파일 로드 오류:", error)
+ toast.error("첨부파일을 불러오는데 실패했습니다")
+ setAttachments([])
+ } finally {
+ setIsLoading(false)
+ }
+ }
+
+ loadAttachments()
+ }, [isOpen, rfqData.id])
+
+ // 파일 다운로드 핸들러
+ const handleDownload = async (attachment: RfqAttachment) => {
+ const attachmentId = attachment.attachmentId
+ setDownloadingFiles(prev => new Set([...prev, attachmentId]))
+
+ try {
+ const result = await downloadFile(
+ attachment.filePath,
+ attachment.originalFileName,
+ {
+ action: 'download',
+ showToast: true,
+ showSuccessToast: true,
+ onSuccess: (fileName, fileSize) => {
+ console.log(`다운로드 완료: ${fileName} (${formatFileSize(fileSize || 0)})`)
+ },
+ onError: (error) => {
+ console.error(`다운로드 실패: ${error}`)
+ }
+ }
+ )
+
+ if (!result.success) {
+ console.error("다운로드 결과:", result)
+ }
+ } catch (error) {
+ console.error("파일 다운로드 오류:", error)
+ toast.error("파일 다운로드에 실패했습니다")
+ } finally {
+ setDownloadingFiles(prev => {
+ const newSet = new Set(prev)
+ newSet.delete(attachmentId)
+ return newSet
+ })
+ }
+ }
+
+ // 파일 미리보기 핸들러
+ const handlePreview = async (attachment: RfqAttachment) => {
+ const fileInfo = getFileInfo(attachment.originalFileName)
+
+ if (!fileInfo.canPreview) {
+ toast.info("이 파일 형식은 미리보기를 지원하지 않습니다. 다운로드를 진행합니다.")
+ return handleDownload(attachment)
+ }
+
+ try {
+ const result = await quickPreview(attachment.filePath, attachment.originalFileName)
+
+ if (!result.success) {
+ console.error("미리보기 결과:", result)
+ }
+ } catch (error) {
+ console.error("파일 미리보기 오류:", error)
+ toast.error("파일 미리보기에 실패했습니다")
+ }
+ }
+
+
+ // 첨부파일 타입별 색상
+ const getAttachmentTypeBadgeVariant = (type: string) => {
+ switch (type.toLowerCase()) {
+ case "견적요청서": return "default"
+ case "기술사양서": return "secondary"
+ case "도면": return "outline"
+ default: return "outline"
+ }
+ }
+
+ return (
+ <Dialog open={isOpen} onOpenChange={onClose}>
+ <DialogContent className="max-w-6xl h-[85vh] flex flex-col">
+ <DialogHeader>
+ <div className="flex items-center justify-between">
+ <div>
+ <DialogTitle>견적 첨부파일</DialogTitle>
+ <DialogDescription>
+ {rfqData.rfqCode} - {rfqData.rfqTitle || rfqData.itemName || "견적"}
+ {attachments.length > 0 && ` (${attachments.length}개 파일)`}
+ </DialogDescription>
+ </div>
+
+ </div>
+ </DialogHeader>
+
+ <ScrollArea className="flex-1">
+ {isLoading ? (
+ <div className="space-y-3">
+ {[...Array(3)].map((_, i) => (
+ <div key={i} className="flex items-center space-x-4 p-3 border rounded-lg">
+ <Skeleton className="h-8 w-8" />
+ <div className="space-y-2 flex-1">
+ <Skeleton className="h-4 w-[300px]" />
+ <Skeleton className="h-3 w-[200px]" />
+ </div>
+ <div className="flex gap-2">
+ <Skeleton className="h-8 w-20" />
+ <Skeleton className="h-8 w-20" />
+ </div>
+ </div>
+ ))}
+ </div>
+ ) : (
+ <Table>
+ <TableHeader>
+ <TableRow>
+ <TableHead className="w-[120px]">타입</TableHead>
+ <TableHead>파일명</TableHead>
+ {/* <TableHead>설명</TableHead> */}
+ <TableHead className="w-[90px]">리비전</TableHead>
+ <TableHead className="w-[100px]">크기</TableHead>
+ <TableHead className="w-[120px]">생성자</TableHead>
+ <TableHead className="w-[120px]">생성일</TableHead>
+ <TableHead className="w-[140px]">액션</TableHead>
+ </TableRow>
+ </TableHeader>
+ <TableBody>
+ {attachments.length === 0 ? (
+ <TableRow>
+ <TableCell colSpan={8} className="text-center text-muted-foreground py-12">
+ <div className="flex flex-col items-center gap-2">
+ <FileText className="h-8 w-8 text-muted-foreground" />
+ <span>첨부된 파일이 없습니다.</span>
+ </div>
+ </TableCell>
+ </TableRow>
+ ) : (
+ attachments.map((attachment) => {
+ const fileInfo = getFileInfo(attachment.originalFileName)
+ const isDownloading = downloadingFiles.has(attachment.attachmentId)
+
+ return (
+ <TableRow key={attachment.attachmentId}>
+ <TableCell>
+ <Badge
+ variant={getAttachmentTypeBadgeVariant(attachment.attachmentType)}
+ className="text-xs"
+ >
+ {attachment.attachmentType}
+ </Badge>
+ </TableCell>
+ <TableCell>
+ <div className="flex items-center gap-2">
+ <span className="text-lg">{fileInfo.icon}</span>
+ <div className="flex flex-col min-w-0">
+ <span className="text-sm font-medium truncate" title={attachment.originalFileName}>
+ {attachment.originalFileName}
+ </span>
+ </div>
+ </div>
+ </TableCell>
+ {/* <TableCell>
+ <span className="text-sm" title={attachment.description || ""}>
+ {attachment.description || "-"}
+ </span>
+ {attachment.revisionComment && (
+ <div className="text-xs text-muted-foreground mt-1">
+ {attachment.revisionComment}
+ </div>
+ )}
+ </TableCell> */}
+ <TableCell>
+ <Badge variant="secondary" className="font-mono text-xs">
+ {attachment.currentRevision}
+ </Badge>
+ </TableCell>
+ <TableCell className="text-xs text-muted-foreground">
+ {attachment.fileSize ? formatFileSize(attachment.fileSize) : "-"}
+ </TableCell>
+ <TableCell className="text-sm">
+ {attachment.createdByName || "-"}
+ </TableCell>
+ <TableCell className="text-xs text-muted-foreground">
+ {attachment.createdAt ? format(new Date(attachment.createdAt), "MM-dd HH:mm") : "-"}
+ </TableCell>
+ <TableCell>
+ <div className="flex items-center gap-1">
+ {/* 미리보기 버튼 (미리보기 가능한 파일만) */}
+ {fileInfo.canPreview && (
+ <Button
+ variant="ghost"
+ size="sm"
+ onClick={() => handlePreview(attachment)}
+ disabled={isDownloading}
+ title="미리보기"
+ >
+ <Eye className="h-4 w-4" />
+ </Button>
+ )}
+
+ {/* 다운로드 버튼 */}
+ <Button
+ variant="ghost"
+ size="sm"
+ onClick={() => handleDownload(attachment)}
+ disabled={isDownloading}
+ title="다운로드"
+ >
+ {isDownloading ? (
+ <Loader2 className="h-4 w-4 animate-spin" />
+ ) : (
+ <Download className="h-4 w-4" />
+ )}
+ </Button>
+
+ {/* 스마트 액션 버튼 (메인 액션) */}
+ {/* <Button
+ variant="outline"
+ size="sm"
+ onClick={() => handleSmartAction(attachment)}
+ disabled={isDownloading}
+ className="ml-1"
+ >
+ {isDownloading ? (
+ <Loader2 className="h-4 w-4 animate-spin mr-1" />
+ ) : fileInfo.canPreview ? (
+ <Eye className="h-4 w-4 mr-1" />
+ ) : (
+ <Download className="h-4 w-4 mr-1" />
+ )}
+ {fileInfo.canPreview ? "보기" : "다운로드"}
+ </Button> */}
+ </div>
+ </TableCell>
+ </TableRow>
+ )
+ })
+ )}
+ </TableBody>
+ </Table>
+ )}
+ </ScrollArea>
+ <div className="flex items-center justify-between border-t pt-4 text-xs text-muted-foreground">
+ {/* 하단 정보 */}
+ {attachments.length > 0 && !isLoading && (
+ <div className="flex justify-between items-center">
+ <span>
+ 총 {attachments.length}개 파일
+ {attachments.some(a => a.fileSize) &&
+ ` · 전체 크기: ${formatFileSize(
+ attachments.reduce((sum, a) => sum + (a.fileSize || 0), 0)
+ )}`
+ }
+ </span>
+ </div>
+ )}
+
+ {/* 전체 다운로드 버튼 추가 */}
+ {attachments.length > 0 && !isLoading && (
+ <Button
+ onClick={handleDownloadAll}
+ disabled={isDownloadingAll}
+ variant="outline"
+ size="sm"
+ >
+ {isDownloadingAll ? (
+ <>
+ <Loader2 className="mr-2 h-4 w-4 animate-spin" />
+ 다운로드 중...
+ </>
+ ) : (
+ <>
+ <Download className="mr-2 h-4 w-4" />
+ 전체 다운로드
+ </>
+ )}
+ </Button>
+ )}
+ </div>
+ </DialogContent>
+ </Dialog>
+ )
+} \ No newline at end of file
diff --git a/lib/rfq-last/vendor-response/rfq-items-dialog.tsx b/lib/rfq-last/vendor-response/rfq-items-dialog.tsx
new file mode 100644
index 00000000..daa692e9
--- /dev/null
+++ b/lib/rfq-last/vendor-response/rfq-items-dialog.tsx
@@ -0,0 +1,354 @@
+"use client"
+
+import * as React from "react"
+import { format } from "date-fns"
+import { Package, ExternalLink } from "lucide-react"
+import {
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogHeader,
+ DialogTitle,
+} from "@/components/ui/dialog"
+import {
+ Table,
+ TableBody,
+ TableCell,
+ TableHead,
+ TableHeader,
+ TableRow,
+} from "@/components/ui/table"
+import { Badge } from "@/components/ui/badge"
+import { Button } from "@/components/ui/button"
+import { ScrollArea } from "@/components/ui/scroll-area"
+import { Skeleton } from "@/components/ui/skeleton"
+import { Separator } from "@/components/ui/separator"
+import { toast } from "sonner"
+import { RfqsLastView } from "@/db/schema"
+import { getRfqItemsAction } from "../service"
+
+// 품목 타입
+interface RfqItem {
+ id: number
+ rfqsLastId: number | null
+ rfqItem: string | null
+ prItem: string | null
+ prNo: string | null
+ materialCode: string | null
+ materialCategory: string | null
+ acc: string | null
+ materialDescription: string | null
+ size: string | null
+ deliveryDate: Date | null
+ quantity: number | null
+ uom: string | null
+ grossWeight: number | null
+ gwUom: string | null
+ specNo: string | null
+ specUrl: string | null
+ trackingNo: string | null
+ majorYn: boolean | null
+ remark: string | null
+ projectDef: string | null
+ projectSc: string | null
+ projectKl: string | null
+ projectLc: string | null
+ projectDl: string | null
+ // RFQ 관련 정보
+ rfqCode: string | null
+ rfqType: string | null
+ rfqTitle: string | null
+ itemCode: string | null
+ itemName: string | null
+ projectCode: string | null
+ projectName: string | null
+}
+
+interface ItemStatistics {
+ total: number
+ major: number
+ regular: number
+ totalQuantity: number
+ totalWeight: number
+}
+
+interface RfqItemsDialogProps {
+ isOpen: boolean
+ onClose: () => void
+ rfqData: RfqsLastView
+}
+
+export function RfqItemsDialog({ isOpen, onClose, rfqData }: RfqItemsDialogProps) {
+ const [items, setItems] = React.useState<RfqItem[]>([])
+ const [statistics, setStatistics] = React.useState<ItemStatistics | null>(null)
+ const [isLoading, setIsLoading] = React.useState(false)
+
+ // 품목 목록 로드
+ React.useEffect(() => {
+ if (!isOpen || !rfqData.id) return
+
+ const loadItems = async () => {
+ setIsLoading(true)
+ try {
+ const result = await getRfqItemsAction(rfqData.id)
+
+ if (result.success) {
+ setItems(result.data)
+ setStatistics(result.statistics)
+ } else {
+ toast.error(result.error || "품목을 불러오는데 실패했습니다")
+ setItems([])
+ setStatistics(null)
+ }
+ } catch (error) {
+ console.error("품목 로드 오류:", error)
+ toast.error("품목을 불러오는데 실패했습니다")
+ setItems([])
+ setStatistics(null)
+ } finally {
+ setIsLoading(false)
+ }
+ }
+
+ loadItems()
+ }, [isOpen, rfqData.id])
+
+ // 사양서 링크 열기
+ const handleOpenSpec = (specUrl: string) => {
+ window.open(specUrl, '_blank', 'noopener,noreferrer')
+ }
+
+ // 수량 포맷팅
+ const formatQuantity = (quantity: number | null, uom: string | null) => {
+ if (!quantity) return "-"
+ return `${quantity.toLocaleString()}${uom ? ` ${uom}` : ""}`
+ }
+
+ // 중량 포맷팅
+ const formatWeight = (weight: number | null, uom: string | null) => {
+ if (!weight) return "-"
+ return `${weight.toLocaleString()} ${uom || "KG"}`
+ }
+
+ return (
+ <Dialog open={isOpen} onOpenChange={onClose}>
+ <DialogContent className="max-w-7xl h-[90vh] flex flex-col">
+ <DialogHeader>
+ <DialogTitle>견적 품목 목록</DialogTitle>
+ <DialogDescription>
+ {rfqData.rfqCode} - {rfqData.rfqTitle || rfqData.itemName || "품목 정보"}
+ </DialogDescription>
+ </DialogHeader>
+
+ {/* 통계 정보 */}
+ {statistics && !isLoading && (
+ <>
+<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-5 gap-3 py-3">
+<div className="text-center">
+ <div className="text-2xl font-bold text-primary">{statistics.total}</div>
+ <div className="text-xs text-muted-foreground">전체 품목</div>
+ </div>
+ <div className="text-center">
+ <div className="text-2xl font-bold text-blue-600">{statistics.major}</div>
+ <div className="text-xs text-muted-foreground">주요 품목</div>
+ </div>
+ <div className="text-center">
+ <div className="text-2xl font-bold text-gray-600">{statistics.regular}</div>
+ <div className="text-xs text-muted-foreground">일반 품목</div>
+ </div>
+ <div className="text-center">
+ <div className="text-2xl font-bold text-green-600">{statistics.totalQuantity.toLocaleString()}</div>
+ <div className="text-xs text-muted-foreground">총 수량</div>
+ </div>
+ <div className="text-center">
+ <div className="text-2xl font-bold text-orange-600">{statistics.totalWeight.toLocaleString()}</div>
+ <div className="text-xs text-muted-foreground">총 중량 (KG)</div>
+ </div>
+ </div>
+ <Separator />
+ </>
+ )}
+
+ <ScrollArea className="flex-1">
+ {isLoading ? (
+ <Table>
+ <TableHeader>
+ <TableRow>
+ <TableHead className="w-[60px]">구분</TableHead>
+ <TableHead className="w-[120px]">자재코드</TableHead>
+ <TableHead>자재명</TableHead>
+ <TableHead className="w-[100px]">수량</TableHead>
+ <TableHead className="w-[100px]">중량</TableHead>
+ <TableHead className="w-[100px]">납기일</TableHead>
+ <TableHead className="w-[100px]">PR번호</TableHead>
+ <TableHead className="w-[80px]">사양</TableHead>
+ <TableHead>비고</TableHead>
+ </TableRow>
+ </TableHeader>
+ <TableBody>
+ {[...Array(3)].map((_, i) => (
+ <TableRow key={i}>
+ <TableCell><Skeleton className="h-8 w-full" /></TableCell>
+ <TableCell><Skeleton className="h-8 w-full" /></TableCell>
+ <TableCell><Skeleton className="h-8 w-full" /></TableCell>
+ <TableCell><Skeleton className="h-8 w-full" /></TableCell>
+ <TableCell><Skeleton className="h-8 w-full" /></TableCell>
+ <TableCell><Skeleton className="h-8 w-full" /></TableCell>
+ <TableCell><Skeleton className="h-8 w-full" /></TableCell>
+ <TableCell><Skeleton className="h-8 w-full" /></TableCell>
+ <TableCell><Skeleton className="h-8 w-full" /></TableCell>
+ </TableRow>
+ ))}
+ </TableBody>
+ </Table>
+ ) : items.length === 0 ? (
+ <div className="text-center text-muted-foreground py-12">
+ <Package className="h-12 w-12 mx-auto mb-4 text-muted-foreground" />
+ <p>품목이 없습니다.</p>
+ </div>
+ ) : (
+ <Table>
+ <TableHeader>
+ <TableRow>
+ <TableHead className="w-[60px]">구분</TableHead>
+ <TableHead className="w-[120px]">자재코드</TableHead>
+ <TableHead>자재명</TableHead>
+ <TableHead className="w-[100px]">수량</TableHead>
+ <TableHead className="w-[100px]">중량</TableHead>
+ <TableHead className="w-[100px]">납기일</TableHead>
+ <TableHead className="w-[100px]">PR번호</TableHead>
+ <TableHead className="w-[100px]">사양</TableHead>
+ <TableHead className="w-[100px]">프로젝트</TableHead>
+ <TableHead>비고</TableHead>
+ </TableRow>
+ </TableHeader>
+ <TableBody>
+ {items.map((item, index) => (
+ <TableRow key={item.id} className={item.majorYn ? "bg-blue-50 border-l-4 border-l-blue-500" : ""}>
+ <TableCell>
+ <div className="flex flex-col items-center gap-1">
+ <span className="text-xs font-mono">#{index + 1}</span>
+ {item.majorYn && (
+ <Badge variant="default" className="text-xs px-1 py-0">
+ 주요
+ </Badge>
+ )}
+ </div>
+ </TableCell>
+ <TableCell>
+ <div className="flex flex-col">
+ <span className="font-mono text-sm font-medium">{item.materialCode || "-"}</span>
+ {item.acc && (
+ <span className="text-xs text-muted-foreground font-mono">
+ ACC: {item.acc}
+ </span>
+ )}
+ </div>
+ </TableCell>
+ <TableCell>
+ <div className="flex flex-col">
+ <span className="text-sm font-medium" title={item.materialDescription || ""}>
+ {item.materialDescription || "-"}
+ </span>
+ {item.materialCategory && (
+ <span className="text-xs text-muted-foreground">
+ {item.materialCategory}
+ </span>
+ )}
+ {item.size && (
+ <span className="text-xs text-muted-foreground">
+ 크기: {item.size}
+ </span>
+ )}
+ </div>
+ </TableCell>
+ <TableCell>
+ <span className="text-sm font-medium">
+ {formatQuantity(item.quantity, item.uom)}
+ </span>
+ </TableCell>
+ <TableCell>
+ <span className="text-sm">
+ {formatWeight(item.grossWeight, item.gwUom)}
+ </span>
+ </TableCell>
+ <TableCell>
+ <span className="text-sm">
+ {item.deliveryDate ? format(new Date(item.deliveryDate), "yyyy-MM-dd") : "-"}
+ </span>
+ </TableCell>
+ <TableCell>
+ <div className="flex flex-col">
+ <span className="text-xs font-mono">{item.prNo || "-"}</span>
+ {item.prItem && item.prItem !== item.prNo && (
+ <span className="text-xs text-muted-foreground font-mono">
+ {item.prItem}
+ </span>
+ )}
+ </div>
+ </TableCell>
+ <TableCell>
+ <div className="flex items-center gap-1">
+ {item.specNo && (
+ <span className="text-xs font-mono">{item.specNo}</span>
+ )}
+ {item.specUrl && (
+ <Button
+ variant="ghost"
+ size="sm"
+ className="h-5 w-5 p-0"
+ onClick={() => handleOpenSpec(item.specUrl!)}
+ title="사양서 열기"
+ >
+ <ExternalLink className="h-3 w-3" />
+ </Button>
+ )}
+ {item.trackingNo && (
+ <div className="text-xs text-muted-foreground">
+ TRK: {item.trackingNo}
+ </div>
+ )}
+ </div>
+ </TableCell>
+ <TableCell>
+ <div className="text-xs">
+ {[
+ item.projectDef && `DEF: ${item.projectDef}`,
+ item.projectSc && `SC: ${item.projectSc}`,
+ item.projectKl && `KL: ${item.projectKl}`,
+ item.projectLc && `LC: ${item.projectLc}`,
+ item.projectDl && `DL: ${item.projectDl}`
+ ].filter(Boolean).join(" | ") || "-"}
+ </div>
+ </TableCell>
+ <TableCell>
+ <span className="text-xs" title={item.remark || ""}>
+ {item.remark ? (item.remark.length > 30 ? `${item.remark.slice(0, 30)}...` : item.remark) : "-"}
+ </span>
+ </TableCell>
+ </TableRow>
+ ))}
+ </TableBody>
+ </Table>
+ )}
+ </ScrollArea>
+
+ {/* 하단 통계 정보 */}
+ {statistics && !isLoading && (
+ <div className="border-t pt-3 text-xs text-muted-foreground">
+ <div className="flex justify-between items-center">
+ <span>
+ 총 {statistics.total}개 품목
+ (주요: {statistics.major}개, 일반: {statistics.regular}개)
+ </span>
+ <span>
+ 전체 수량: {statistics.totalQuantity.toLocaleString()} |
+ 전체 중량: {statistics.totalWeight.toLocaleString()} KG
+ </span>
+ </div>
+ </div>
+ )}
+ </DialogContent>
+ </Dialog>
+ )
+} \ No newline at end of file
diff --git a/lib/rfq-last/vendor-response/service.ts b/lib/rfq-last/vendor-response/service.ts
new file mode 100644
index 00000000..7de3ae58
--- /dev/null
+++ b/lib/rfq-last/vendor-response/service.ts
@@ -0,0 +1,483 @@
+// getVendorQuotationsLast.ts
+'use server'
+
+import { revalidatePath, unstable_cache } from "next/cache";
+import db from "@/db/db";
+import { and, or, eq, desc, asc, count, ilike, inArray } from "drizzle-orm";
+import {
+ rfqsLastView,
+ rfqLastDetails,
+ rfqLastVendorResponses,
+ type RfqsLastView
+} from "@/db/schema";
+import { filterColumns } from "@/lib/filter-columns";
+import type { GetQuotationsLastSchema, UpdateParticipationSchema } from "@/lib/rfq-last/vendor-response/validations";
+import { getServerSession } from "next-auth/next"
+import { authOptions } from "@/app/api/auth/[...nextauth]/route"
+import { getRfqAttachmentsAction } from "../service";
+
+
+export type VendorQuotationStatus =
+ | "미응답" // 초대받았지만 참여 여부 미결정
+ | "불참" // 참여 거절
+ | "작성중" // 참여 후 작성중
+ | "제출완료" // 견적서 제출 완료
+ | "수정요청" // 구매자가 수정 요청
+ | "최종확정" // 최종 확정됨
+ | "취소" // 취소됨
+
+// 벤더 견적 뷰 타입 확장
+export interface VendorQuotationView extends RfqsLastView {
+ // 벤더 응답 정보
+ responseStatus?: VendorQuotationStatus;
+ displayStatus?:string;
+ responseVersion?: number;
+ submittedAt?: Date;
+ totalAmount?: number;
+ vendorCurrency?: string;
+
+ // 벤더별 조건
+ vendorPaymentTerms?: string;
+ vendorIncoterms?: string;
+ vendorDeliveryDate?: Date;
+
+ participationStatus: "미응답" | "참여" | "불참" | null
+ participationRepliedAt: Date | null
+ nonParticipationReason: string | null
+}
+
+/**
+ * 벤더별 RFQ 목록 조회
+ */
+export async function getVendorQuotationsLast(
+ input: GetQuotationsLastSchema,
+ vendorId: string
+) {
+ return unstable_cache(
+ async () => {
+ try {
+ const numericVendorId = parseInt(vendorId);
+ if (isNaN(numericVendorId)) {
+ return { data: [], pageCount: 0 };
+ }
+
+ // 페이지네이션 설정
+ const page = input.page || 1;
+ const perPage = input.perPage || 10;
+ const offset = (page - 1) * perPage;
+
+ // 1. 먼저 벤더가 포함된 RFQ ID들 조회
+ const vendorRfqIds = await db
+ .select({ rfqsLastId: rfqLastDetails.rfqsLastId })
+ .from(rfqLastDetails)
+ .where(
+ and(
+ eq(rfqLastDetails.vendorsId, numericVendorId),
+ eq(rfqLastDetails.isLatest, true)
+ )
+ );
+
+
+ const rfqIds = vendorRfqIds.map(r => r.rfqsLastId).filter(id => id !== null);
+
+ if (rfqIds.length === 0) {
+ return { data: [], pageCount: 0 };
+ }
+
+ // 2. 필터링 설정
+ // advancedTable 모드로 where 절 구성
+ const advancedWhere = filterColumns({
+ table: rfqsLastView,
+ filters: input.filters,
+ joinOperator: input.joinOperator,
+ });
+
+ // 글로벌 검색 조건
+ let globalWhere;
+ if (input.search) {
+ const s = `%${input.search}%`;
+ globalWhere = or(
+ ilike(rfqsLastView.rfqCode, s),
+ ilike(rfqsLastView.rfqTitle, s),
+ ilike(rfqsLastView.itemName, s),
+ ilike(rfqsLastView.projectName, s),
+ ilike(rfqsLastView.packageName, s),
+ ilike(rfqsLastView.status, s)
+ );
+ }
+
+ // RFQ ID 조건 (벤더가 포함된 RFQ만)
+ const rfqIdWhere = inArray(rfqsLastView.id, rfqIds);
+
+ // 모든 조건 결합
+ let whereConditions = [rfqIdWhere]; // 필수 조건
+ if (advancedWhere) whereConditions.push(advancedWhere);
+ if (globalWhere) whereConditions.push(globalWhere);
+
+ // 최종 조건
+ const finalWhere = and(...whereConditions);
+
+ // 3. 정렬 설정
+ const orderBy = input.sort && input.sort.length > 0
+ ? input.sort.map((item) => {
+ // @ts-ignore - 동적 속성 접근
+ return item.desc ? desc(rfqsLastView[item.id]) : asc(rfqsLastView[item.id]);
+ })
+ : [desc(rfqsLastView.updatedAt)];
+
+ // 4. 메인 쿼리 실행
+ const quotations = await db
+ .select()
+ .from(rfqsLastView)
+ .where(finalWhere)
+ .orderBy(...orderBy)
+ .limit(perPage)
+ .offset(offset);
+
+ // 5. 각 RFQ에 대한 벤더 응답 정보 조회
+ const quotationsWithResponse = await Promise.all(
+ quotations.map(async (rfq) => {
+ // 벤더 응답 정보 조회
+ const response = await db.query.rfqLastVendorResponses.findFirst({
+ where: and(
+ eq(rfqLastVendorResponses.rfqsLastId, rfq.id),
+ eq(rfqLastVendorResponses.vendorId, numericVendorId),
+ eq(rfqLastVendorResponses.isLatest, true)
+ ),
+ columns: {
+ status: true,
+ responseVersion: true,
+ submittedAt: true,
+ totalAmount: true,
+ vendorCurrency: true,
+ vendorPaymentTermsCode: true,
+ vendorIncotermsCode: true,
+ vendorDeliveryDate: true,
+ participationStatus: true,
+ participationRepliedAt: true,
+ nonParticipationReason: true,
+ }
+ });
+
+ // 벤더 상세 정보 조회
+ const detail = await db.query.rfqLastDetails.findFirst({
+ where: and(
+ eq(rfqLastDetails.rfqsLastId, rfq.id),
+ eq(rfqLastDetails.vendorsId, numericVendorId),
+ eq(rfqLastDetails.isLatest, true)
+ ),
+ columns: {
+ id: true, // rfqLastDetailsId 필요
+ emailSentAt: true,
+ emailStatus: true,
+ shortList: true,
+ }
+ });
+
+ // 표시할 상태 결정 (새로운 로직)
+ let displayStatus: string | null = null;
+
+ if (response) {
+ // 응답 레코드가 있는 경우
+ if (response.participationStatus === "불참") {
+ displayStatus = "불참";
+ } else if (response.participationStatus === "참여") {
+ // 참여한 경우 실제 작업 상태 표시
+ displayStatus = response.status || "작성중";
+ } else {
+ // participationStatus가 없거나 "미응답"인 경우
+ displayStatus = "미응답";
+ }
+ } else {
+ // 응답 레코드가 없는 경우
+ if (detail?.emailSentAt) {
+ displayStatus = "미응답"; // 초대는 받았지만 응답 안함
+ } else {
+ displayStatus = null; // 아직 초대도 안됨
+ }
+ }
+
+ return {
+ ...rfq,
+ // 새로운 상태 체계
+ displayStatus, // UI에서 표시할 통합 상태
+
+ // 참여 관련 정보
+ participationStatus: response?.participationStatus || "미응답",
+ participationRepliedAt: response?.participationRepliedAt,
+ nonParticipationReason: response?.nonParticipationReason,
+
+ // 견적 작업 상태 (참여한 경우에만 의미 있음)
+ responseStatus: response?.status,
+ responseVersion: response?.responseVersion,
+ submittedAt: response?.submittedAt,
+ totalAmount: response?.totalAmount,
+ vendorCurrency: response?.vendorCurrency,
+ vendorPaymentTerms: response?.vendorPaymentTermsCode,
+ vendorIncoterms: response?.vendorIncotermsCode,
+ vendorDeliveryDate: response?.vendorDeliveryDate,
+
+ // 초대 관련 정보
+ rfqLastDetailsId: detail?.id, // 참여 결정 시 필요
+ emailSentAt: detail?.emailSentAt,
+ emailStatus: detail?.emailStatus,
+ shortList: detail?.shortList,
+ } as VendorQuotationView;
+ })
+ );
+
+ // 6. 전체 개수 조회
+ const { totalCount } = await db
+ .select({ totalCount: count() })
+ .from(rfqsLastView)
+ .where(finalWhere)
+ .then(rows => rows[0]);
+
+ // 페이지 수 계산
+ const pageCount = Math.ceil(Number(totalCount) / perPage);
+
+
+ return {
+ data: quotationsWithResponse,
+ pageCount
+ };
+ } catch (err) {
+ console.error("getVendorQuotationsLast 에러:", err);
+ return { data: [], pageCount: 0 };
+ }
+ },
+ [`vendor-quotations-last-${vendorId}-${JSON.stringify(input)}`],
+ {
+ revalidate: 60,
+ tags: [`vendor-quotations-last-${vendorId}`],
+ }
+ )();
+}
+
+
+
+export async function getQuotationStatusCountsLast(vendorId: string) {
+ return unstable_cache(
+ async () => {
+ try {
+ const numericVendorId = parseInt(vendorId);
+ if (isNaN(numericVendorId)) {
+ return {
+ "미응답": 0,
+ "불참": 0,
+ "작성중": 0,
+ "제출완료": 0,
+ "수정요청": 0,
+ "최종확정": 0,
+ "취소": 0,
+ } as Record<VendorQuotationStatus, number>;
+ }
+
+ // 1. 벤더가 초대받은 전체 RFQ 조회
+ const invitedRfqs = await db
+ .select({
+ rfqsLastId: rfqLastDetails.rfqsLastId,
+ })
+ .from(rfqLastDetails)
+ .where(
+ and(
+ eq(rfqLastDetails.vendorsId, numericVendorId),
+ eq(rfqLastDetails.isLatest, true)
+ )
+ );
+
+ const invitedRfqIds = invitedRfqs.map(r => r.rfqsLastId);
+ const totalInvited = invitedRfqIds.length;
+
+ // 초대받은 RFQ가 없으면 모두 0 반환
+ if (totalInvited === 0) {
+ return {
+ "미응답": 0,
+ "불참": 0,
+ "작성중": 0,
+ "제출완료": 0,
+ "수정요청": 0,
+ "최종확정": 0,
+ "취소": 0,
+ } as Record<VendorQuotationStatus, number>;
+ }
+
+ // 2. 벤더의 응답 상태 조회
+ const vendorResponses = await db
+ .select({
+ participationStatus: rfqLastVendorResponses.participationStatus,
+ status: rfqLastVendorResponses.status,
+ rfqsLastId: rfqLastVendorResponses.rfqsLastId,
+ })
+ .from(rfqLastVendorResponses)
+ .where(
+ and(
+ eq(rfqLastVendorResponses.vendorId, numericVendorId),
+ eq(rfqLastVendorResponses.isLatest, true),
+ inArray(rfqLastVendorResponses.rfqsLastId, invitedRfqIds)
+ )
+ );
+
+ // 3. 상태별 카운트 계산
+ const result: Record<VendorQuotationStatus, number> = {
+ "미응답": 0,
+ "불참": 0,
+ "작성중": 0,
+ "제출완료": 0,
+ "수정요청": 0,
+ "최종확정": 0,
+ "취소": 0,
+ };
+
+ // 응답이 있는 RFQ ID 세트
+ const respondedRfqIds = new Set(vendorResponses.map(r => r.rfqsLastId));
+
+ // 미응답 = 초대받았지만 응답 레코드가 없거나 participationStatus가 미응답인 경우
+ result["미응답"] = totalInvited - respondedRfqIds.size;
+
+ // 응답별 상태 카운트
+ vendorResponses.forEach(response => {
+ // 불참한 경우
+ if (response.participationStatus === "불참") {
+ result["불참"]++;
+ }
+ // 참여했지만 아직 participationStatus가 없는 경우 (기존 데이터 호환성)
+ else if (!response.participationStatus || response.participationStatus === "미응답") {
+ // 응답 레코드는 있지만 참여 여부 미결정
+ result["미응답"]++;
+ }
+ // 참여한 경우 - status에 따라 분류
+ else if (response.participationStatus === "참여") {
+ switch (response.status) {
+ case "대기중":
+ case "작성중":
+ result["작성중"]++;
+ break;
+ case "제출완료":
+ result["제출완료"]++;
+ break;
+ case "수정요청":
+ result["수정요청"]++;
+ break;
+ case "최종확정":
+ result["최종확정"]++;
+ break;
+ case "취소":
+ result["취소"]++;
+ break;
+ default:
+ // 기존 상태 호환성 처리
+ if (response.status === "초대됨") {
+ result["미응답"]++;
+ } else if (response.status === "제출완료" || response.status === "Submitted") {
+ result["제출완료"]++;
+ }
+ break;
+ }
+ }
+ });
+
+ return result;
+ } catch (err) {
+ console.error("getQuotationStatusCountsLast 에러:", err);
+ return {
+ "미응답": 0,
+ "불참": 0,
+ "작성중": 0,
+ "제출완료": 0,
+ "수정요청": 0,
+ "최종확정": 0,
+ "취소": 0,
+ } as Record<VendorQuotationStatus, number>;
+ }
+ },
+ [`quotation-status-counts-last-${vendorId}`],
+ {
+ revalidate: 60,
+ tags: [`quotation-status-last-${vendorId}`],
+ }
+ )();
+}
+
+export async function updateParticipationStatus(
+ input: UpdateParticipationSchema
+) {
+ try {
+ const session = await getServerSession(authOptions)
+ if (!session?.user?.id) {
+ throw new Error("인증이 필요합니다.")
+ }
+
+ const vendorId = session.user.companyId;
+ const { rfqId, rfqLastDetailsId, participationStatus, nonParticipationReason } = input
+
+ // 기존 응답 레코드 찾기 또는 생성
+ const existingResponse = await db
+ .select()
+ .from(rfqLastVendorResponses)
+ .where(
+ and(
+ eq(rfqLastVendorResponses.rfqsLastId, rfqId),
+ eq(rfqLastVendorResponses.vendorId, Number(vendorId)),
+ eq(rfqLastVendorResponses.rfqLastDetailsId, rfqLastDetailsId),
+ eq(rfqLastVendorResponses.isLatest, true)
+ )
+ )
+ .limit(1)
+
+
+ const now = new Date()
+ const userId = parseInt(session.user.id)
+
+ if (existingResponse.length > 0) {
+ // 기존 레코드 업데이트
+ await db
+ .update(rfqLastVendorResponses)
+ .set({
+ participationStatus,
+ participationRepliedAt: now,
+ participationRepliedBy: userId,
+ nonParticipationReason: participationStatus === "불참" ? nonParticipationReason : null,
+ status: participationStatus === "참여" ? "작성중" : "대기중",
+ updatedAt: now,
+ updatedBy:userId,
+ })
+ .where(eq(rfqLastVendorResponses.id, existingResponse[0].id))
+
+ }
+
+ // revalidatePath("/vendor/quotations")
+
+ return {
+ success: true,
+ message: participationStatus === "참여"
+ ? "견적 참여가 확정되었습니다."
+ : "견적 불참이 처리되었습니다."
+ }
+ } catch (error) {
+ console.error("참여 여부 업데이트 에러:", error)
+ return {
+ success: false,
+ message: "참여 여부 업데이트 중 오류가 발생했습니다."
+ }
+ }
+}
+
+
+
+interface UpdateVendorContractRequirementsParams {
+ rfqId: number;
+ detailId: number;
+ contractRequirements: {
+ agreementYn: boolean;
+ ndaYn: boolean;
+ gtcType: "general" | "project" | "none";
+ };
+}
+
+interface UpdateResult {
+ success: boolean;
+ error?: string;
+ data?: any;
+}
+
diff --git a/lib/rfq-last/vendor-response/validations.ts b/lib/rfq-last/vendor-response/validations.ts
new file mode 100644
index 00000000..033154c2
--- /dev/null
+++ b/lib/rfq-last/vendor-response/validations.ts
@@ -0,0 +1,42 @@
+import { createSearchParamsCache,
+ parseAsArrayOf,
+ parseAsInteger,
+ parseAsString,
+ parseAsStringEnum,parseAsBoolean
+} from "nuqs/server"
+import * as z from "zod"
+
+import { getFiltersStateParser, getSortingStateParser } from "@/lib/parsers"
+import { RfqsLastView } from "@/db/schema";
+
+
+
+export const searchParamsVendorRfqCache = createSearchParamsCache({
+ flags: parseAsArrayOf(z.enum(["advancedTable", "floatingBar"])).withDefault([]),
+ page: parseAsInteger.withDefault(1),
+ perPage: parseAsInteger.withDefault(10),
+ sort: getSortingStateParser<RfqsLastView>().withDefault([
+ { id: "updatedAt", desc: true },
+ ]),
+
+ // 고급 필터
+ filters: getFiltersStateParser().withDefault([]),
+ joinOperator: parseAsStringEnum(["and", "or"]).withDefault("and"),
+
+ search: parseAsString.withDefault(""),
+ from: parseAsString.withDefault(""),
+ to: parseAsString.withDefault(""),
+});
+
+export type GetQuotationsLastSchema = Awaited<ReturnType<typeof searchParamsVendorRfqCache.parse>>;
+
+// 참여 여부 업데이트 스키마
+export const updateParticipationSchema = z.object({
+ rfqId: z.number(),
+ rfqLastDetailsId: z.number(),
+ participationStatus: z.enum(["참여", "불참"]),
+ nonParticipationReason: z.string().optional(),
+})
+
+
+export type UpdateParticipationSchema = z.infer<typeof updateParticipationSchema>;
diff --git a/lib/rfq-last/vendor-response/vendor-quotations-table-columns.tsx b/lib/rfq-last/vendor-response/vendor-quotations-table-columns.tsx
new file mode 100644
index 00000000..144c6c43
--- /dev/null
+++ b/lib/rfq-last/vendor-response/vendor-quotations-table-columns.tsx
@@ -0,0 +1,514 @@
+"use client"
+
+import * as React from "react"
+import { type DataTableRowAction } from "@/types/table"
+import { type ColumnDef } from "@tanstack/react-table"
+import {
+ FileText,
+ Edit,
+ Send,
+ Eye,
+ Clock,
+ CheckCircle,
+ AlertCircle,
+ XCircle,
+ Mail,
+ UserX
+} from "lucide-react"
+import { formatCurrency, formatDate, formatDateTime } from "@/lib/utils"
+import { Badge } from "@/components/ui/badge"
+import { Button } from "@/components/ui/button"
+import { Checkbox } from "@/components/ui/checkbox"
+import {
+ Tooltip,
+ TooltipContent,
+ TooltipProvider,
+ TooltipTrigger,
+} from "@/components/ui/tooltip"
+import { DataTableColumnHeaderSimple } from "@/components/data-table/data-table-column-simple-header"
+import { useRouter } from "next/navigation"
+import type { VendorQuotationView } from "./service"
+import { ParticipationDialog } from "./participation-dialog"
+
+// 통합 상태 배지 컴포넌트 (displayStatus 사용)
+function DisplayStatusBadge({ status }: { status: string | null }) {
+ if (!status) return null
+
+ const config = {
+ "미응답": { variant: "secondary" as const, icon: Mail, label: "응답 대기" },
+ "불참": { variant: "destructive" as const, icon: UserX, label: "불참" },
+ "작성중": { variant: "outline" as const, icon: Edit, label: "작성중" },
+ "제출완료": { variant: "default" as const, icon: CheckCircle, label: "제출완료" },
+ "수정요청": { variant: "warning" as const, icon: AlertCircle, label: "수정요청" },
+ "최종확정": { variant: "success" as const, icon: CheckCircle, label: "최종확정" },
+ "취소": { variant: "destructive" as const, icon: XCircle, label: "취소" },
+ }
+
+ const { variant, icon: Icon, label } = config[status as keyof typeof config] || {
+ variant: "outline" as const,
+ icon: Clock,
+ label: status
+ }
+
+ return (
+ <Badge variant={variant} className="gap-1">
+ <Icon className="h-3 w-3" />
+ {label}
+ </Badge>
+ )
+}
+
+// RFQ 상태 배지 (기존 유지)
+function RfqStatusBadge({ status }: { status: string }) {
+ const config: Record<string, { variant: "default" | "secondary" | "outline" | "destructive" | "warning" | "success" }> = {
+ "RFQ 생성": { variant: "outline" },
+ "구매담당지정": { variant: "secondary" },
+ "견적요청문서 확정": { variant: "secondary" },
+ "TBE 완료": { variant: "warning" },
+ "RFQ 발송": { variant: "default" },
+ "견적접수": { variant: "success" },
+ "최종업체선정": { variant: "success" },
+ }
+
+ const { variant } = config[status] || { variant: "outline" as const }
+ return <Badge variant={variant}>{status}</Badge>
+}
+
+type NextRouter = ReturnType<typeof useRouter>
+
+interface GetColumnsProps {
+ setRowAction: React.Dispatch<React.SetStateAction<DataTableRowAction<VendorQuotationView> | null>>;
+ router: NextRouter;
+ vendorId: number; // 추가: 벤더 ID 전달
+}
+
+export function getColumns({
+ setRowAction,
+ router,
+ vendorId, // 추가
+}: GetColumnsProps): ColumnDef<VendorQuotationView>[] {
+
+ // 체크박스 컬럼 (기존 유지)
+ const selectColumn: ColumnDef<VendorQuotationView> = {
+ id: "select",
+ header: ({ table }) => (
+ <Checkbox
+ checked={
+ table.getIsAllPageRowsSelected() ||
+ (table.getIsSomePageRowsSelected() && "indeterminate")
+ }
+ onCheckedChange={(value) => table.toggleAllPageRowsSelected(!!value)}
+ aria-label="Select all"
+ className="translate-y-0.5"
+ />
+ ),
+ cell: ({ row }) => (
+ <Checkbox
+ checked={row.getIsSelected()}
+ onCheckedChange={(value) => row.toggleSelected(!!value)}
+ aria-label="Select row"
+ className="translate-y-0.5"
+ />
+ ),
+ size: 40,
+ enableSorting: false,
+ enableHiding: false,
+ }
+
+ // 액션 컬럼
+ const actionsColumn: ColumnDef<VendorQuotationView> = {
+ id: "actions",
+ header: "작업",
+ enableHiding: false,
+ cell: ({ row }) => {
+ const rfqId = row.original.id
+ const rfqCode = row.original.rfqCode
+ const displayStatus = row.original.displayStatus
+ const rfqLastDetailsId = row.original.rfqLastDetailsId
+ const [showParticipationDialog, setShowParticipationDialog] = React.useState(false)
+
+ // displayStatus 기반으로 액션 결정
+ switch (displayStatus) {
+ case "미응답":
+ return (
+ <>
+ <Button
+ variant="default"
+ size="sm"
+ onClick={() => setShowParticipationDialog(true)}
+ className="h-8"
+ >
+ <Mail className="h-4 w-4 mr-1" />
+ 참여 여부 결정
+ </Button>
+ {showParticipationDialog && (
+ <ParticipationDialog
+ rfqId={rfqId}
+ rfqCode={rfqCode}
+ rfqLastDetailsId={rfqLastDetailsId}
+ currentStatus={displayStatus}
+ onClose={() => setShowParticipationDialog(false)}
+ />
+ )}
+ </>
+ )
+
+ case "불참":
+ return (
+ <TooltipProvider>
+ <Tooltip>
+ <TooltipTrigger asChild>
+ <span className="text-sm text-muted-foreground">불참</span>
+ </TooltipTrigger>
+ {row.original.nonParticipationReason && (
+ <TooltipContent>
+ <p className="max-w-xs">
+ 불참 사유: {row.original.nonParticipationReason}
+ </p>
+ </TooltipContent>
+ )}
+ </Tooltip>
+ </TooltipProvider>
+ )
+
+ case "작성중":
+ case "대기중":
+ return (
+ <Button
+ variant="default"
+ size="sm"
+ onClick={() => router.push(`/partners/rfq-last/${rfqId}`)}
+ className="h-8"
+ >
+ <Edit className="h-4 w-4 mr-1" />
+ 견적서 작성
+ </Button>
+ )
+
+ case "수정요청":
+ return (
+ <Button
+ variant="warning"
+ size="sm"
+ onClick={() => router.push(`/partners/rfq-last/${rfqId}`)}
+ className="h-8"
+ >
+ <AlertCircle className="h-4 w-4 mr-1" />
+ 견적서 수정
+ </Button>
+ )
+
+ case "제출완료":
+ case "최종확정":
+ return (
+ <Button
+ variant="outline"
+ size="sm"
+ onClick={() => router.push(`/partners/rfq-last/${rfqId}`)}
+ className="h-8"
+ >
+ <Eye className="h-4 w-4 mr-1" />
+ 견적서 보기
+ </Button>
+ )
+
+ case "취소":
+ return (
+ <span className="text-sm text-muted-foreground">취소됨</span>
+ )
+
+ default:
+ return null
+ }
+ },
+ size: 150,
+ }
+
+ // 기본 컬럼들
+ const columns: ColumnDef<VendorQuotationView>[] = [
+ selectColumn,
+ {
+ accessorKey: "rfqCode",
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="RFQ 번호" />
+ ),
+ cell: ({ row }) => {
+ const value = row.getValue("rfqCode")
+ return (
+ <span className="font-mono text-sm font-medium">
+ {value || "-"}
+ </span>
+ )
+ },
+ size: 140,
+ minSize: 120,
+ maxSize: 180,
+ enableResizing: true,
+ },
+ {
+ accessorKey: "rfqType",
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="RFQ 유형" />
+ ),
+ cell: ({ row }) => {
+ const rfqCode = row.original.rfqCode
+ const value = row.getValue("rfqType")
+
+ // F로 시작하지 않으면 빈 값 반환
+ if (!rfqCode?.startsWith('F')) {
+ return null
+ }
+
+ const typeMap: Record<string, string> = {
+ "ITB": "ITB",
+ "RFQ": "RFQ",
+ "일반견적": "일반견적"
+ }
+ return typeMap[value as string] || value || "-"
+ },
+ size: 100,
+ minSize: 80,
+ maxSize: 120,
+ enableResizing: true,
+ // F로 시작하지 않을 때 컬럼 숨기기
+ enableHiding: true,
+ },
+ {
+ accessorKey: "rfqTitle",
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="RFQ 제목" />
+ ),
+ cell: ({ row }) => {
+ const rfqCode = row.original.rfqCode
+ const value = row.getValue("rfqTitle")
+
+ // F로 시작하지 않으면 빈 값 반환
+ if (!rfqCode?.startsWith('F')) {
+ return null
+ }
+
+ return value || "-"
+ },
+ minSize: 200,
+ maxSize: 400,
+ enableResizing: true,
+ enableHiding: true,
+ },
+ {
+ accessorKey: "projectName",
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="프로젝트" />
+ ),
+ cell: ({ row }) => (
+ <div className="flex flex-col">
+ <span className="font-mono text-xs text-muted-foreground">
+ {row.original.projectCode}
+ </span>
+ <span className="max-w-[200px] truncate" title={row.original.projectName || ""}>
+ {row.original.projectName || "-"}
+ </span>
+ </div>
+ ),
+ minSize: 150,
+ maxSize: 300,
+ enableResizing: true,
+ },
+ {
+ accessorKey: "itemName",
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="품목명" />
+ ),
+ cell: ({ row }) => row.getValue("itemName") || "-",
+ minSize: 150,
+ maxSize: 300,
+ enableResizing: true,
+ },
+ {
+ accessorKey: "packageName",
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="패키지" />
+ ),
+ cell: ({ row }) => (
+ <div className="flex flex-col">
+ <span className="font-mono text-xs text-muted-foreground">
+ {row.original.packageNo}
+ </span>
+ <span className="max-w-[200px] truncate" title={row.original.packageName || ""}>
+ {row.original.packageName || "-"}
+ </span>
+ </div>
+ ),
+ minSize: 120,
+ maxSize: 250,
+ enableResizing: true,
+ },
+ {
+ accessorKey: "MaterialGroup",
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="자재그룹" />
+ ),
+ cell: ({ row }) => (
+ <div className="flex flex-col">
+ <span className="font-mono text-xs text-muted-foreground">
+ {row.original.majorItemMaterialCategory}
+ </span>
+ <span className="max-w-[200px] truncate" title={row.original.majorItemMaterialDescription || ""}>
+ {row.original.majorItemMaterialDescription || "-"}
+ </span>
+ </div>
+ ),
+ minSize: 120,
+ maxSize: 250,
+ enableResizing: true,
+ },
+
+ {
+ id: "rfqDocument",
+ header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="견적 자료" />,
+ cell: ({ row }) => (
+ <Button
+ variant="ghost"
+ size="sm"
+ onClick={() => setRowAction({ row, type: "attachment" })}
+ >
+ <FileText className="h-4 w-4" />
+ </Button>
+ ),
+ size: 80,
+ },
+ // 견적품목수 - 수정됨
+ {
+ accessorKey: "prItemsCount",
+ header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="견적품목수" />,
+ cell: ({ row }) => (
+ <Button
+ variant="ghost"
+ size="sm"
+ className="font-mono text-sm p-1 h-auto"
+ onClick={() => setRowAction({ row, type: "items" })}
+ >
+ {row.original.prItemsCount || 0}
+ </Button>
+ ),
+ size: 90,
+ },
+
+ {
+ accessorKey: "engPicName",
+ header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="설계담당자" />,
+ cell: ({ row }) => row.original.engPicName || "-",
+ size: 100,
+ },
+
+ {
+ accessorKey: "picUserName",
+ header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="구매담당자" />,
+ cell: ({ row }) => row.original.picUserName || row.original.picName || "-",
+ size: 100,
+ },
+
+ {
+ accessorKey: "submittedAt",
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="제출일" />
+ ),
+ cell: ({ row }) => {
+ return row.original.submittedAt
+ ? formatDateTime(new Date(row.original.submittedAt))
+ : "-"
+ },
+ size: 150,
+ minSize: 120,
+ maxSize: 180,
+ enableResizing: true,
+ },
+ {
+ accessorKey: "totalAmount",
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="견적 금액" />
+ ),
+ cell: ({ row }) => {
+ if (!row.original.totalAmount) return "-"
+ return formatCurrency(
+ row.original.totalAmount,
+ row.original.vendorCurrency || "USD"
+ )
+ },
+ size: 140,
+ minSize: 120,
+ maxSize: 180,
+ enableResizing: true,
+ },
+ {
+ accessorKey: "displayStatus", // 변경: responseStatus → displayStatus
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="상태" />
+ ),
+ cell: ({ row }) => <DisplayStatusBadge status={row.original.displayStatus} />,
+ size: 120,
+ minSize: 100,
+ maxSize: 150,
+ enableResizing: true,
+ },
+ {
+ accessorKey: "rfqSendDate",
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="발송일" />
+ ),
+ cell: ({ row }) => {
+ const value = row.getValue("rfqSendDate")
+ return value ? formatDateTime(new Date(value as string)) : "-"
+ },
+ size: 150,
+ minSize: 120,
+ maxSize: 180,
+ enableResizing: true,
+ },
+ {
+ accessorKey: "participationRepliedAt", // 추가: 참여 응답일
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="참여 응답일" />
+ ),
+ cell: ({ row }) => {
+ const value = row.getValue("participationRepliedAt")
+ return value ? formatDateTime(new Date(value as string)) : "-"
+ },
+ size: 150,
+ minSize: 120,
+ maxSize: 180,
+ enableResizing: true,
+ enableHiding: true, // 선택적 표시
+ },
+ {
+ accessorKey: "dueDate",
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="마감일" />
+ ),
+ cell: ({ row }) => {
+ const value = row.getValue("dueDate")
+ const now = new Date()
+ const dueDate = value ? new Date(value as string) : null
+ const isOverdue = dueDate && dueDate < now
+ const isNearDeadline = dueDate &&
+ (dueDate.getTime() - now.getTime()) < (24 * 60 * 60 * 1000) // 24시간 이내
+
+ return (
+ <span className={
+ isOverdue ? "text-red-600 font-semibold" :
+ isNearDeadline ? "text-orange-600 font-semibold" :
+ ""
+ }>
+ {dueDate ? formatDateTime(dueDate) : "-"}
+ </span>
+ )
+ },
+ size: 150,
+ minSize: 120,
+ maxSize: 180,
+ enableResizing: true,
+ },
+ actionsColumn,
+ ]
+
+ return columns
+} \ No newline at end of file
diff --git a/lib/rfq-last/vendor-response/vendor-quotations-table.tsx b/lib/rfq-last/vendor-response/vendor-quotations-table.tsx
new file mode 100644
index 00000000..683a0318
--- /dev/null
+++ b/lib/rfq-last/vendor-response/vendor-quotations-table.tsx
@@ -0,0 +1,171 @@
+// vendor-quotations-table-last.tsx
+"use client"
+
+import * as React from "react"
+import {
+ type DataTableAdvancedFilterField,
+ type DataTableFilterField,
+ type DataTableRowAction
+} from "@/types/table"
+import { useDataTable } from "@/hooks/use-data-table"
+import { DataTable } from "@/components/data-table/data-table"
+import { DataTableAdvancedToolbar } from "@/components/data-table/data-table-advanced-toolbar"
+import { useRouter } from "next/navigation"
+import { getColumns } from "./vendor-quotations-table-columns"
+import type { VendorQuotationView } from "./service"
+import { RfqAttachmentsDialog } from "./rfq-attachments-dialog";
+import { RfqItemsDialog } from "./rfq-items-dialog";
+
+interface VendorQuotationsTableLastProps {
+ promises: Promise<[{ data: VendorQuotationView[], pageCount: number }]>
+}
+
+export function VendorQuotationsTableLast({ promises }: VendorQuotationsTableLastProps) {
+ const [{ data, pageCount }] = React.use(promises)
+ const router = useRouter()
+
+ console.log(data,"VendorQuotationsTableLast")
+
+ const [rowAction, setRowAction] = React.useState<DataTableRowAction<VendorQuotationView> | null>(null)
+
+ // 테이블 컬럼
+ const columns = React.useMemo(() => getColumns({
+ setRowAction,
+ router,
+ }), [setRowAction, router])
+
+ // 응답 상태별 카운트
+ const statusCounts = React.useMemo(() => {
+ return {
+ notResponded: data.filter(q => q.displayStatus === "미응답").length,
+ declined: data.filter(q => q.displayStatus === "불참").length,
+ drafting: data.filter(q => q.displayStatus === "작성중").length,
+ submitted: data.filter(q => q.displayStatus === "제출완료").length,
+ revisionRequested: data.filter(q => q.displayStatus === "수정요청").length,
+ confirmed: data.filter(q => q.displayStatus === "최종확정").length,
+ cancelled: data.filter(q => q.displayStatus === "취소").length,
+ }
+ }, [data])
+
+ // 필터 필드
+ const filterFields: DataTableFilterField<VendorQuotationView>[] = [
+ {
+ id: "displayStatus",
+ label: "상태",
+ options: [
+ { label: "미응답", value: "미응답", count: statusCounts.notResponded },
+ { label: "불참", value: "불참", count: statusCounts.declined },
+ { label: "작성중", value: "작성중", count: statusCounts.drafting },
+ { label: "제출완료", value: "제출완료", count: statusCounts.submitted },
+ { label: "수정요청", value: "수정요청", count: statusCounts.revisionRequested },
+ { label: "최종확정", value: "최종확정", count: statusCounts.confirmed },
+ { label: "취소", value: "취소", count: statusCounts.cancelled },
+ ]
+ },
+ {
+ id: "rfqCode",
+ label: "RFQ 번호",
+ placeholder: "RFQ 번호 검색...",
+ },
+ ]
+
+ // 고급 필터 필드
+ const advancedFilterFields: DataTableAdvancedFilterField<VendorQuotationView>[] = [
+ {
+ id: "rfqCode",
+ label: "RFQ 번호",
+ type: "text",
+ },
+ {
+ id: "rfqTitle",
+ label: "RFQ 제목",
+ type: "text",
+ },
+ {
+ id: "projectName",
+ label: "프로젝트명",
+ type: "text",
+ },
+ {
+ id: "displayStatus",
+ label: "상태",
+ options: [
+ { label: "미응답", value: "미응답", count: statusCounts.notResponded },
+ { label: "불참", value: "불참", count: statusCounts.declined },
+ { label: "작성중", value: "작성중", count: statusCounts.drafting },
+ { label: "제출완료", value: "제출완료", count: statusCounts.submitted },
+ { label: "수정요청", value: "수정요청", count: statusCounts.revisionRequested },
+ { label: "최종확정", value: "최종확정", count: statusCounts.confirmed },
+ { label: "취소", value: "취소", count: statusCounts.cancelled },
+ ]
+ },
+ {
+ id: "dueDate",
+ label: "마감일",
+ type: "date",
+ },
+ {
+ id: "submittedAt",
+ label: "제출일",
+ type: "date",
+ },
+ ]
+
+ // useDataTable 훅 사용
+ const { table } = useDataTable({
+ data,
+ columns,
+ pageCount,
+ filterFields,
+ enablePinning: true,
+ enableAdvancedFilter: true,
+ enableColumnResizing: true,
+ columnResizeMode: 'onChange',
+ initialState: {
+ sorting: [{ id: "updatedAt", desc: true }],
+ columnPinning: { right: ["actions"] },
+ },
+ getRowId: (originalRow) => String(originalRow.id),
+ shallow: false,
+ clearOnDefault: true,
+ defaultColumn: {
+ minSize: 50,
+ maxSize: 500,
+ },
+ })
+
+ return (
+ // <div className="w-full">
+ <>
+ <div className="overflow-x-auto">
+ <DataTable
+ table={table}
+ className="min-w-full"
+ >
+ <DataTableAdvancedToolbar
+ table={table}
+ filterFields={advancedFilterFields}
+ shallow={false}
+ />
+ </DataTable>
+ </div>
+ {/* 다이얼로그들 */}
+ {rowAction?.type === "attachment" && (
+ <RfqAttachmentsDialog
+ isOpen={true}
+ onClose={() => setRowAction(null)}
+ rfqData={rowAction.row.original}
+ />
+ )}
+
+ {rowAction?.type === "items" && (
+ <RfqItemsDialog
+ isOpen={true}
+ onClose={() => setRowAction(null)}
+ rfqData={rowAction.row.original}
+ />
+ )}
+ </>
+ // </div>
+ )
+} \ No newline at end of file