summaryrefslogtreecommitdiff
path: root/lib/techsales-rfq/vendor-response/quotation-editor.tsx
diff options
context:
space:
mode:
authordujinkim <dujin.kim@dtsolution.co.kr>2025-05-28 19:03:21 +0000
committerdujinkim <dujin.kim@dtsolution.co.kr>2025-05-28 19:03:21 +0000
commit5036cf2908792cef45f06256e71f10920f647f49 (patch)
tree3116e7419e872d45025d1d48e6ddaffe2ba2dd38 /lib/techsales-rfq/vendor-response/quotation-editor.tsx
parent7ae037e9c2fc0be1fe68cecb461c5e1e837cb0da (diff)
(김준회) 기술영업 조선 RFQ (SHI/벤더)
Diffstat (limited to 'lib/techsales-rfq/vendor-response/quotation-editor.tsx')
-rw-r--r--lib/techsales-rfq/vendor-response/quotation-editor.tsx559
1 files changed, 559 insertions, 0 deletions
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<typeof quotationFormSchema>
+
+// 통화 타입
+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<Currency[]>([])
+ const [loadingCurrencies, setLoadingCurrencies] = useState(true)
+
+ // 폼 초기화
+ const form = useForm<QuotationFormValues>({
+ 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 <Badge variant={config.variant}>{config.label}</Badge>
+ }
+
+ return (
+ <div className="container max-w-4xl mx-auto py-6 space-y-6">
+ {/* 헤더 */}
+ <div className="flex items-center justify-between">
+ <div className="flex items-center space-x-4">
+ <Button
+ variant="ghost"
+ size="sm"
+ onClick={() => router.back()}
+ >
+ <ArrowLeft className="h-4 w-4 mr-2" />
+ 뒤로가기
+ </Button>
+ <div>
+ <h1 className="text-2xl font-bold">기술영업 견적서</h1>
+ <p className="text-muted-foreground">
+ RFQ: {quotation.rfq.rfqCode} | {getStatusBadge(quotation.status)}
+ </p>
+ </div>
+ </div>
+ <div className="flex items-center space-x-2">
+ {isEditable() && (
+ <>
+ <Button
+ variant="outline"
+ onClick={handleSave}
+ disabled={isSaving}
+ >
+ <Save className="h-4 w-4 mr-2" />
+ {isSaving ? "저장 중..." : "저장"}
+ </Button>
+ <Button
+ onClick={handleSubmit}
+ disabled={isSubmitting || !isSubmitReady()}
+ >
+ <Send className="h-4 w-4 mr-2" />
+ {isSubmitting ? "제출 중..." : "제출"}
+ </Button>
+ </>
+ )}
+ </div>
+ </div>
+
+ <div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
+ {/* 왼쪽: RFQ 정보 */}
+ <div className="lg:col-span-1 space-y-6">
+ {/* RFQ 기본 정보 */}
+ <Card>
+ <CardHeader>
+ <CardTitle>RFQ 정보</CardTitle>
+ </CardHeader>
+ <CardContent className="space-y-4">
+ <div>
+ <label className="text-sm font-medium text-muted-foreground">RFQ 번호</label>
+ <p className="font-mono">{quotation.rfq.rfqCode}</p>
+ </div>
+ <div>
+ <label className="text-sm font-medium text-muted-foreground">자재 코드</label>
+ <p>{quotation.rfq.materialCode || "N/A"}</p>
+ </div>
+ <div>
+ <label className="text-sm font-medium text-muted-foreground">품목명</label>
+ <p>{quotation.rfq.item?.itemName || "N/A"}</p>
+ </div>
+ <div>
+ <label className="text-sm font-medium text-muted-foreground">마감일</label>
+ <p className={!isBeforeDueDate() ? "text-red-600 font-medium" : ""}>
+ {quotation.rfq.dueDate ? formatDate(quotation.rfq.dueDate) : "N/A"}
+ </p>
+ </div>
+ {quotation.rfq.remark && (
+ <div>
+ <label className="text-sm font-medium text-muted-foreground">비고</label>
+ <p className="text-sm">{quotation.rfq.remark}</p>
+ </div>
+ )}
+ </CardContent>
+ </Card>
+
+ {/* 프로젝트 정보 */}
+ {quotation.rfq.projectSnapshot && (
+ <Card>
+ <CardHeader>
+ <CardTitle>프로젝트 정보</CardTitle>
+ </CardHeader>
+ <CardContent className="space-y-3">
+ <div>
+ <label className="text-sm font-medium text-muted-foreground">프로젝트 번호</label>
+ <p className="font-mono">{quotation.rfq.projectSnapshot.pspid}</p>
+ </div>
+ <div>
+ <label className="text-sm font-medium text-muted-foreground">프로젝트명</label>
+ <p>{quotation.rfq.projectSnapshot.projNm || "N/A"}</p>
+ </div>
+ <div>
+ <label className="text-sm font-medium text-muted-foreground">선종</label>
+ <p>{quotation.rfq.projectSnapshot.ptypeNm || "N/A"}</p>
+ </div>
+ <div>
+ <label className="text-sm font-medium text-muted-foreground">척수</label>
+ <p>{quotation.rfq.projectSnapshot.projMsrm || "N/A"}</p>
+ </div>
+ <div>
+ <label className="text-sm font-medium text-muted-foreground">선주</label>
+ <p>{quotation.rfq.projectSnapshot.kunnrNm || "N/A"}</p>
+ </div>
+ </CardContent>
+ </Card>
+ )}
+
+ {/* 시리즈 정보 */}
+ {quotation.rfq.seriesSnapshot && quotation.rfq.seriesSnapshot.length > 0 && (
+ <Card>
+ <CardHeader>
+ <CardTitle>시리즈 일정</CardTitle>
+ </CardHeader>
+ <CardContent>
+ <div className="space-y-3">
+ {quotation.rfq.seriesSnapshot.map((series, index) => (
+ <div key={index} className="border rounded p-3">
+ <div className="font-medium mb-2">시리즈 {series.sersNo}</div>
+ <div className="grid grid-cols-2 gap-2 text-sm">
+ {series.klDt && (
+ <div>
+ <span className="text-muted-foreground">K/L:</span> {formatDate(series.klDt)}
+ </div>
+ )}
+ {series.dlDt && (
+ <div>
+ <span className="text-muted-foreground">인도:</span> {formatDate(series.dlDt)}
+ </div>
+ )}
+ </div>
+ </div>
+ ))}
+ </div>
+ </CardContent>
+ </Card>
+ )}
+ </div>
+
+ {/* 오른쪽: 견적서 입력 폼 */}
+ <div className="lg:col-span-2">
+ <Card>
+ <CardHeader>
+ <CardTitle>견적서 작성</CardTitle>
+ <CardDescription>
+ 총액 기반으로 견적을 작성해주세요.
+ </CardDescription>
+ </CardHeader>
+ <CardContent>
+ <Form {...form}>
+ <form className="space-y-6">
+ {/* 통화 선택 */}
+ <FormField
+ control={form.control}
+ name="currency"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>통화 *</FormLabel>
+ <Select
+ onValueChange={field.onChange}
+ defaultValue={field.value}
+ disabled={!isEditable()}
+ >
+ <FormControl>
+ <SelectTrigger>
+ <SelectValue placeholder="통화를 선택하세요" />
+ </SelectTrigger>
+ </FormControl>
+ <SelectContent>
+ {loadingCurrencies ? (
+ <div className="p-2">
+ <Skeleton className="h-4 w-full" />
+ </div>
+ ) : (
+ currencies.map((currency) => (
+ <SelectItem key={currency.code} value={currency.code}>
+ {currency.code} - {currency.name}
+ </SelectItem>
+ ))
+ )}
+ </SelectContent>
+ </Select>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+
+ {/* 총액 입력 */}
+ <FormField
+ control={form.control}
+ name="totalPrice"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>총액 *</FormLabel>
+ <FormControl>
+ <Input
+ type="number"
+ step="0.01"
+ placeholder="총액을 입력하세요"
+ disabled={!isEditable()}
+ {...field}
+ />
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+
+ {/* 유효기간 */}
+ <FormField
+ control={form.control}
+ name="validUntil"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>견적 유효기간 *</FormLabel>
+ <FormControl>
+ <DatePicker
+ date={field.value}
+ onDateChange={field.onChange}
+ disabled={!isEditable()}
+ placeholder="유효기간을 선택하세요"
+ />
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+
+ {/* 비고 */}
+ <FormField
+ control={form.control}
+ name="remark"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>비고</FormLabel>
+ <FormControl>
+ <Textarea
+ placeholder="추가 설명이나 특이사항을 입력하세요"
+ disabled={!isEditable()}
+ rows={4}
+ {...field}
+ />
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+
+ {/* 반려 사유 (반려된 경우에만 표시) */}
+ {quotation.status === "Rejected" && quotation.rejectionReason && (
+ <div className="p-4 bg-red-50 border border-red-200 rounded-lg">
+ <label className="text-sm font-medium text-red-800">반려 사유</label>
+ <p className="text-sm text-red-700 mt-1">{quotation.rejectionReason}</p>
+ </div>
+ )}
+
+ {/* 제출 정보 */}
+ {quotation.submittedAt && (
+ <div className="p-4 bg-blue-50 border border-blue-200 rounded-lg">
+ <label className="text-sm font-medium text-blue-800">제출 정보</label>
+ <p className="text-sm text-blue-700 mt-1">
+ 제출일: {formatDate(quotation.submittedAt)}
+ </p>
+ </div>
+ )}
+ </form>
+ </Form>
+ </CardContent>
+ </Card>
+ </div>
+ </div>
+ </div>
+ )
+} \ No newline at end of file