From 5036cf2908792cef45f06256e71f10920f647f49 Mon Sep 17 00:00:00 2001 From: dujinkim Date: Wed, 28 May 2025 19:03:21 +0000 Subject: (김준회) 기술영업 조선 RFQ (SHI/벤더) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../vendor-response/quotation-editor.tsx | 559 +++++++++++++++++++++ 1 file changed, 559 insertions(+) create mode 100644 lib/techsales-rfq/vendor-response/quotation-editor.tsx (limited to 'lib/techsales-rfq/vendor-response/quotation-editor.tsx') diff --git a/lib/techsales-rfq/vendor-response/quotation-editor.tsx b/lib/techsales-rfq/vendor-response/quotation-editor.tsx new file mode 100644 index 00000000..f3fab10d --- /dev/null +++ b/lib/techsales-rfq/vendor-response/quotation-editor.tsx @@ -0,0 +1,559 @@ +"use client" + +import * as React from "react" +import { useState, useEffect } from "react" +import { useForm } from "react-hook-form" +import { zodResolver } from "@hookform/resolvers/zod" +import * as z from "zod" +import { toast } from "sonner" +import { useRouter } from "next/navigation" +import { CalendarIcon, Save, Send, ArrowLeft } from "lucide-react" + +import { Button } from "@/components/ui/button" +import { + Form, + FormControl, + FormField, + FormItem, + FormLabel, + FormMessage, +} from "@/components/ui/form" +import { Input } from "@/components/ui/input" +import { Textarea } from "@/components/ui/textarea" +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card" +import { Badge } from "@/components/ui/badge" +import { Separator } from "@/components/ui/separator" +import { DatePicker } from "@/components/ui/date-picker" +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select" +import { Skeleton } from "@/components/ui/skeleton" + +import { formatCurrency, formatDate } from "@/lib/utils" +import { + updateTechSalesVendorQuotation, + submitTechSalesVendorQuotation, + fetchCurrencies +} from "../service" + +// 견적서 폼 스키마 (techsales용 단순화) +const quotationFormSchema = z.object({ + currency: z.string().min(1, "통화를 선택해주세요"), + totalPrice: z.string().min(1, "총액을 입력해주세요"), + validUntil: z.date({ + required_error: "견적 유효기간을 선택해주세요", + invalid_type_error: "유효한 날짜를 선택해주세요", + }), + remark: z.string().optional(), +}) + +type QuotationFormValues = z.infer + +// 통화 타입 +interface Currency { + code: string + name: string +} + +// 이 컴포넌트에 전달되는 견적서 데이터 타입 (techsales용 단순화) +interface TechSalesVendorQuotation { + id: number + rfqId: number + vendorId: number + quotationCode: string | null + quotationVersion: number | null + totalPrice: string | null + currency: string | null + validUntil: Date | null + status: "Draft" | "Submitted" | "Revised" | "Rejected" | "Accepted" + remark: string | null + rejectionReason: string | null + submittedAt: Date | null + acceptedAt: Date | null + createdAt: Date + updatedAt: Date + rfq: { + id: number + rfqCode: string | null + dueDate: Date | null + status: string | null + materialCode: string | null + remark: string | null + projectSnapshot?: { + pspid?: string + projNm?: string + sector?: string + projMsrm?: number + kunnr?: string + kunnrNm?: string + ptypeNm?: string + } | null + seriesSnapshot?: Array<{ + pspid: string + sersNo: string + scDt?: string + klDt?: string + lcDt?: string + dlDt?: string + dockNo?: string + dockNm?: string + projNo?: string + post1?: string + }> | null + item?: { + id: number + itemCode: string | null + itemName: string | null + } | null + biddingProject?: { + id: number + pspid: string | null + projNm: string | null + } | null + createdByUser?: { + id: number + name: string | null + email: string | null + } | null + } + vendor: { + id: number + vendorName: string + vendorCode: string | null + } +} + +interface TechSalesQuotationEditorProps { + quotation: TechSalesVendorQuotation +} + +export default function TechSalesQuotationEditor({ quotation }: TechSalesQuotationEditorProps) { + const router = useRouter() + const [isSubmitting, setIsSubmitting] = useState(false) + const [isSaving, setIsSaving] = useState(false) + const [currencies, setCurrencies] = useState([]) + const [loadingCurrencies, setLoadingCurrencies] = useState(true) + + // 폼 초기화 + const form = useForm({ + resolver: zodResolver(quotationFormSchema), + defaultValues: { + currency: quotation.currency || "USD", + totalPrice: quotation.totalPrice || "", + validUntil: quotation.validUntil || undefined, + remark: quotation.remark || "", + }, + }) + + // 통화 목록 로드 + useEffect(() => { + const loadCurrencies = async () => { + try { + const { data, error } = await fetchCurrencies() + if (error) { + toast.error("통화 목록을 불러오는데 실패했습니다") + return + } + setCurrencies(data || []) + } catch (error) { + console.error("Error loading currencies:", error) + toast.error("통화 목록을 불러오는데 실패했습니다") + } finally { + setLoadingCurrencies(false) + } + } + + loadCurrencies() + }, []) + + // 마감일 확인 + const isBeforeDueDate = () => { + if (!quotation.rfq.dueDate) return true + return new Date() <= new Date(quotation.rfq.dueDate) + } + + // 편집 가능 여부 확인 + const isEditable = () => { + return quotation.status === "Draft" || quotation.status === "Rejected" + } + + // 제출 가능 여부 확인 + const isSubmitReady = () => { + const values = form.getValues() + return values.currency && + values.totalPrice && + parseFloat(values.totalPrice) > 0 && + values.validUntil && + isBeforeDueDate() + } + + // 저장 핸들러 + const handleSave = async () => { + if (!isEditable()) { + toast.error("편집할 수 없는 상태입니다") + return + } + + setIsSaving(true) + try { + const values = form.getValues() + const { data, error } = await updateTechSalesVendorQuotation({ + id: quotation.id, + currency: values.currency, + totalPrice: values.totalPrice, + validUntil: values.validUntil, + remark: values.remark, + updatedBy: quotation.vendorId, // 임시로 vendorId 사용 + }) + + if (error) { + toast.error(error) + return + } + + toast.success("견적서가 저장되었습니다") + router.refresh() + } catch (error) { + console.error("Error saving quotation:", error) + toast.error("견적서 저장 중 오류가 발생했습니다") + } finally { + setIsSaving(false) + } + } + + // 제출 핸들러 + const handleSubmit = async () => { + if (!isEditable()) { + toast.error("제출할 수 없는 상태입니다") + return + } + + if (!isSubmitReady()) { + toast.error("필수 항목을 모두 입력해주세요") + return + } + + if (!isBeforeDueDate()) { + toast.error("마감일이 지났습니다") + return + } + + setIsSubmitting(true) + try { + const values = form.getValues() + const { data, error } = await submitTechSalesVendorQuotation({ + id: quotation.id, + currency: values.currency, + totalPrice: values.totalPrice, + validUntil: values.validUntil, + remark: values.remark, + updatedBy: quotation.vendorId, // 임시로 vendorId 사용 + }) + + if (error) { + toast.error(error) + return + } + + toast.success("견적서가 제출되었습니다") + router.push("/ko/partners/techsales/rfq-ship") + } catch (error) { + console.error("Error submitting quotation:", error) + toast.error("견적서 제출 중 오류가 발생했습니다") + } finally { + setIsSubmitting(false) + } + } + + // 상태 배지 + const getStatusBadge = (status: string) => { + const statusConfig = { + "Draft": { label: "초안", variant: "secondary" as const }, + "Submitted": { label: "제출됨", variant: "default" as const }, + "Revised": { label: "수정됨", variant: "outline" as const }, + "Rejected": { label: "반려됨", variant: "destructive" as const }, + "Accepted": { label: "승인됨", variant: "success" as const }, + } + + const config = statusConfig[status as keyof typeof statusConfig] || { + label: status, + variant: "secondary" as const + } + + return {config.label} + } + + return ( +
+ {/* 헤더 */} +
+
+ +
+

기술영업 견적서

+

+ RFQ: {quotation.rfq.rfqCode} | {getStatusBadge(quotation.status)} +

+
+
+
+ {isEditable() && ( + <> + + + + )} +
+
+ +
+ {/* 왼쪽: RFQ 정보 */} +
+ {/* RFQ 기본 정보 */} + + + RFQ 정보 + + +
+ +

{quotation.rfq.rfqCode}

+
+
+ +

{quotation.rfq.materialCode || "N/A"}

+
+
+ +

{quotation.rfq.item?.itemName || "N/A"}

+
+
+ +

+ {quotation.rfq.dueDate ? formatDate(quotation.rfq.dueDate) : "N/A"} +

+
+ {quotation.rfq.remark && ( +
+ +

{quotation.rfq.remark}

+
+ )} +
+
+ + {/* 프로젝트 정보 */} + {quotation.rfq.projectSnapshot && ( + + + 프로젝트 정보 + + +
+ +

{quotation.rfq.projectSnapshot.pspid}

+
+
+ +

{quotation.rfq.projectSnapshot.projNm || "N/A"}

+
+
+ +

{quotation.rfq.projectSnapshot.ptypeNm || "N/A"}

+
+
+ +

{quotation.rfq.projectSnapshot.projMsrm || "N/A"}

+
+
+ +

{quotation.rfq.projectSnapshot.kunnrNm || "N/A"}

+
+
+
+ )} + + {/* 시리즈 정보 */} + {quotation.rfq.seriesSnapshot && quotation.rfq.seriesSnapshot.length > 0 && ( + + + 시리즈 일정 + + +
+ {quotation.rfq.seriesSnapshot.map((series, index) => ( +
+
시리즈 {series.sersNo}
+
+ {series.klDt && ( +
+ K/L: {formatDate(series.klDt)} +
+ )} + {series.dlDt && ( +
+ 인도: {formatDate(series.dlDt)} +
+ )} +
+
+ ))} +
+
+
+ )} +
+ + {/* 오른쪽: 견적서 입력 폼 */} +
+ + + 견적서 작성 + + 총액 기반으로 견적을 작성해주세요. + + + +
+ + {/* 통화 선택 */} + ( + + 통화 * + + + + )} + /> + + {/* 총액 입력 */} + ( + + 총액 * + + + + + + )} + /> + + {/* 유효기간 */} + ( + + 견적 유효기간 * + + + + + + )} + /> + + {/* 비고 */} + ( + + 비고 + +