diff options
| author | dujinkim <dujin.kim@dtsolution.co.kr> | 2025-09-14 05:28:01 +0000 |
|---|---|---|
| committer | dujinkim <dujin.kim@dtsolution.co.kr> | 2025-09-14 05:28:01 +0000 |
| commit | 675b4e3d8ffcb57a041db285417d81e61284d900 (patch) | |
| tree | 254f3d6a6c0ce39ae8fba35618f3810e08945f19 /lib/rfq-last/vendor-response/editor/vendor-response-editor.tsx | |
| parent | 39f12cb19f29cbc5568057e154e6adf4789ae736 (diff) | |
(대표님) RFQ-last, tbe-last, 기본계약 템플릿 내 견적,입찰,계약 추가, env.dev NAS_PATH 수정
Diffstat (limited to 'lib/rfq-last/vendor-response/editor/vendor-response-editor.tsx')
| -rw-r--r-- | lib/rfq-last/vendor-response/editor/vendor-response-editor.tsx | 477 |
1 files changed, 477 insertions, 0 deletions
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 |
