summaryrefslogtreecommitdiff
path: root/lib/rfq-last/vendor-response/editor/vendor-response-editor.tsx
diff options
context:
space:
mode:
authordujinkim <dujin.kim@dtsolution.co.kr>2025-09-14 05:28:01 +0000
committerdujinkim <dujin.kim@dtsolution.co.kr>2025-09-14 05:28:01 +0000
commit675b4e3d8ffcb57a041db285417d81e61284d900 (patch)
tree254f3d6a6c0ce39ae8fba35618f3810e08945f19 /lib/rfq-last/vendor-response/editor/vendor-response-editor.tsx
parent39f12cb19f29cbc5568057e154e6adf4789ae736 (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.tsx477
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