diff options
Diffstat (limited to 'lib/techsales-rfq/vendor-response/quotation-editor.tsx')
| -rw-r--r-- | lib/techsales-rfq/vendor-response/quotation-editor.tsx | 559 |
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 |
