"use client" import { useState,useEffect } 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" interface FileWithType extends File { attachmentType?: "구매" | "설계" description?: string } 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 quotationItemSchema = 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(), }).passthrough(); // ⬅️ 여기가 핵심: 정의 안 된 키도 유지 // 폼 스키마 정의 const vendorResponseSchema = z.object({ // 상업 조건 vendorCurrency: z.string().optional(), vendorPaymentTermsCode: z.string().optional(), vendorIncotermsCode: z.string().optional(), vendorIncotermsDetail: z.string().nullable().optional(), vendorDeliveryDate: z.date().optional().nullable(), vendorContractDuration: z.string().nullable().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(), priceAdjustmentForm: z.object({ priceAdjustmentResponse: z.boolean().nullable().optional(), itemName: z.string().optional(), adjustmentReflectionPoint: z.string().optional(), adjustmentRatio: z.number().optional(), adjustmentPeriod: z.string().optional(), referenceDate: z.string().optional(), comparisonDate: z.string().optional(), adjustmentDate: z.string().optional(), contractorWriter: z.string().optional(), majorApplicableRawMaterial: z.string().optional(), adjustmentFormula: z.string().optional(), rawMaterialPriceIndex: z.string().optional(), adjustmentConditions: z.string().optional(), notes: z.string().optional(), majorNonApplicableRawMaterial: z.string().optional(), nonApplicableReason: z.string().optional(), }).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(quotationItemSchema), }) type VendorResponseFormData = z.infer 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([]) const [existingAttachments, setExistingAttachments] = useState([]) const [deletedAttachments, setDeletedAttachments] = useState([]) const [uploadProgress, setUploadProgress] = useState(0) // 추가 console.log(existingResponse,"existingResponse") // 제출완료 상태 확인 const isSubmitted = existingResponse?.status === "제출완료" || existingResponse?.submission?.submittedAt // existingResponse가 변경될 때 existingAttachments 초기화 useEffect(() => { if (existingResponse?.attachments) { setExistingAttachments([...existingResponse.attachments]) setDeletedAttachments([]) // 삭제 목록 초기화 } else { setExistingAttachments([]) setDeletedAttachments([]) } }, [existingResponse?.attachments]) // 기존 첨부파일 삭제 처리 const handleExistingAttachmentsChange = (files: any[]) => { const currentAttachments = existingResponse?.attachments || [] const deleted = currentAttachments.filter( curr => !files.some(f => f.id === curr.id) ) setExistingAttachments(files) setDeletedAttachments(prev => [...prev, ...deleted]) } // 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 || "", priceAdjustmentForm: existingResponse?.priceAdjustmentForm ? { priceAdjustmentResponse: existingResponse.priceAdjustmentForm.majorApplicableRawMaterial ? true : existingResponse.priceAdjustmentForm.majorNonApplicableRawMaterial ? false : null, itemName: existingResponse.priceAdjustmentForm.itemName || "", adjustmentReflectionPoint: existingResponse.priceAdjustmentForm.adjustmentReflectionPoint || "", adjustmentRatio: existingResponse.priceAdjustmentForm.adjustmentRatio ? Number(existingResponse.priceAdjustmentForm.adjustmentRatio) : undefined, adjustmentPeriod: existingResponse.priceAdjustmentForm.adjustmentPeriod || "", referenceDate: existingResponse.priceAdjustmentForm.referenceDate ? (typeof existingResponse.priceAdjustmentForm.referenceDate === 'string' ? existingResponse.priceAdjustmentForm.referenceDate : existingResponse.priceAdjustmentForm.referenceDate.toISOString().split('T')[0]) : "", comparisonDate: existingResponse.priceAdjustmentForm.comparisonDate ? (typeof existingResponse.priceAdjustmentForm.comparisonDate === 'string' ? existingResponse.priceAdjustmentForm.comparisonDate : existingResponse.priceAdjustmentForm.comparisonDate.toISOString().split('T')[0]) : "", adjustmentDate: existingResponse.priceAdjustmentForm.adjustmentDate ? (typeof existingResponse.priceAdjustmentForm.adjustmentDate === 'string' ? existingResponse.priceAdjustmentForm.adjustmentDate : existingResponse.priceAdjustmentForm.adjustmentDate.toISOString().split('T')[0]) : "", contractorWriter: existingResponse.priceAdjustmentForm.contractorWriter || "", majorApplicableRawMaterial: existingResponse.priceAdjustmentForm.majorApplicableRawMaterial || "", adjustmentFormula: existingResponse.priceAdjustmentForm.adjustmentFormula || "", rawMaterialPriceIndex: existingResponse.priceAdjustmentForm.rawMaterialPriceIndex || "", adjustmentConditions: existingResponse.priceAdjustmentForm.adjustmentConditions || "", notes: existingResponse.priceAdjustmentForm.notes || "", majorNonApplicableRawMaterial: existingResponse.priceAdjustmentForm.majorNonApplicableRawMaterial || "", nonApplicableReason: existingResponse.priceAdjustmentForm.nonApplicableReason || "", } : { priceAdjustmentResponse: null, itemName: "", adjustmentReflectionPoint: "", adjustmentRatio: undefined, adjustmentPeriod: "", referenceDate: "", comparisonDate: "", adjustmentDate: "", contractorWriter: "", majorApplicableRawMaterial: "", adjustmentFormula: "", rawMaterialPriceIndex: "", adjustmentConditions: "", notes: "", majorNonApplicableRawMaterial: "", nonApplicableReason: "", }, 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({ resolver: zodResolver(vendorResponseSchema), defaultValues, mode: 'onChange' // 추가: 실시간 validation }) const { formState: { errors, isValid } } = methods useEffect(() => { if (Object.keys(errors).length > 0) { console.log('Validation errors:', errors) } }, [errors]) console.log(methods.getValues()) const handleFormSubmit = (isSubmit: boolean = false) => { // 임시저장일 경우 validation 없이 바로 저장 if (!isSubmit) { const formData = methods.getValues() onSubmit(formData, false) return } // 제출일 경우에만 validation 수행 methods.handleSubmit( (data) => onSubmit(data, isSubmit), (errors) => { console.error('Form validation errors:', errors) // 첫 번째 에러 필드로 포커스 이동 const firstErrorField = Object.keys(errors)[0] if (firstErrorField) { // 어느 탭에 에러가 있는지 확인 if (firstErrorField.startsWith('vendor') && !firstErrorField.startsWith('vendorFirst') && !firstErrorField.startsWith('vendorSparepart')) { setActiveTab('terms') } else if (firstErrorField === 'quotationItems') { setActiveTab('items') } // 구체적인 에러 메시지 표시 if (errors.quotationItems) { toast.error("견적 품목 정보를 확인해주세요. 모든 품목의 단가와 총액을 입력해야 합니다.") } else { toast.error("입력 정보를 확인해주세요.") } } } )() } const onSubmit = async (data: VendorResponseFormData, isSubmit: boolean = false) => { console.log('onSubmit called with:', { data, isSubmit, attachmentsCount: attachments.length }) // 디버깅용 setLoading(true) setUploadProgress(0) try { const formData = new FormData() // 첨부파일 메타데이터 생성 시 타입 확인 const fileMetadata = attachments.map((file: FileWithType) => { const metadata = { attachmentType: file.attachmentType || "기타", description: file.description || "" }; console.log(`파일 메타데이터 생성: ${file.name} -> 타입: ${metadata.attachmentType}`); return metadata; }); // 디버그: 첨부파일 attachmentType 확인 console.log('최종 첨부파일 목록:', attachments.map(f => ({ name: f.name, attachmentType: f.attachmentType, size: f.size }))) console.log('파일 메타데이터:', fileMetadata) // 기본 데이터 추가 const submitData = { ...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, fileMetadata, } console.log('Submitting data:', submitData) // 디버깅용 formData.append('data', JSON.stringify(submitData)) // 첨부파일 추가 (메타데이터를 통해 타입 정보 전달) attachments.forEach((file, index) => { const metadata = fileMetadata[index]; console.log(`첨부파일 추가: ${file.name}, 타입: ${metadata?.attachmentType}`); formData.append(`attachments`, file) }) // XMLHttpRequest 사용하여 업로드 진행률 추적 const xhr = new XMLHttpRequest() 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) try { const response = JSON.parse(xhr.responseText) resolve(response) } catch (e) { console.error('Response parsing error:', e) reject(new Error('응답 파싱 실패')) } } else { console.error('Server error:', xhr.status, xhr.responseText) reject(new Error(`서버 오류: ${xhr.status}`)) } }) xhr.addEventListener('error', () => { console.error('Network error') reject(new Error('네트워크 오류가 발생했습니다.')) }) // 요청 전송 const method = existingResponse ? 'PUT' : 'POST' const url = `/api/partners/rfq-last/${rfq.id}/response` console.log(`Sending ${method} request to ${url}`) // 디버깅용 xhr.open(method, url) xhr.send(formData) }) await uploadPromise // 임시저장 성공 시 첨부파일 목록 초기화 (중복 저장 방지) if (!isSubmit) { console.log('임시저장 완료 - 첨부파일 목록 초기화'); setAttachments([]); setExistingAttachments([]); setDeletedAttachments([]); } toast.success(isSubmit ? "견적서가 제출되었습니다." : "견적서가 저장되었습니다.") if (isSubmit) { router.push('/partners/rfq-last') } router.refresh() } catch (error) { console.error('Submit error:', error) // 더 상세한 에러 로깅 toast.error(error instanceof Error ? error.message : "오류가 발생했습니다.") } finally { setLoading(false) setUploadProgress(0) } } 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 (
{ e.preventDefault() // 기본 submit 동작 방지 handleFormSubmit(false) }}>
{/* 헤더 정보 */} {/* 견적 총액 표시 */} {totalAmount > 0 && (
견적 총액 {formatCurrency(totalAmount, methods.watch('vendorCurrency') || 'USD')}
)} {/* 탭 콘텐츠 */} 기본계약 상업조건 견적품목 첨부파일 기본계약 정보 이 RFQ에 요청된 기본계약 목록 및 상태입니다 {basicContracts.length > 0 ? (
{/* 계약 목록 - 그리드 레이아웃 */}
{basicContracts.map((contract) => (

{contract.templateName}

{contract.signedAt ? ( <> 서명완료 ) : ( <> 서명대기 )}

{contract.signedAt ? `${formatDate(new Date(contract.signedAt))}` : contract.deadline ? `~${formatDate(new Date(contract.deadline))}` : '마감일 없음'}

))}
{/* 서명 상태 요약 및 액션 */} {basicContracts.some(contract => !contract.signedAt) ? (

서명 대기: {basicContracts.filter(c => !c.signedAt).length}/{basicContracts.length}개

견적서 제출 전 모든 계약서 서명 필요

) : ( 모든 기본계약 서명 완료 )}
) : (

이 RFQ에 요청된 기본계약이 없습니다

)}
{/* 하단 액션 버튼 */} {loading && uploadProgress > 0 && (
파일 업로드 중... {uploadProgress}%

대용량 파일 업로드 시 시간이 걸릴 수 있습니다. 창을 닫지 마세요.

)}
{!isSubmitted && ( )}
) }