summaryrefslogtreecommitdiff
path: root/lib/procurement-rfqs/vendor-response/quotation-editor.tsx
diff options
context:
space:
mode:
Diffstat (limited to 'lib/procurement-rfqs/vendor-response/quotation-editor.tsx')
-rw-r--r--lib/procurement-rfqs/vendor-response/quotation-editor.tsx953
1 files changed, 953 insertions, 0 deletions
diff --git a/lib/procurement-rfqs/vendor-response/quotation-editor.tsx b/lib/procurement-rfqs/vendor-response/quotation-editor.tsx
new file mode 100644
index 00000000..963c2f85
--- /dev/null
+++ b/lib/procurement-rfqs/vendor-response/quotation-editor.tsx
@@ -0,0 +1,953 @@
+"use client"
+
+import * as React from "react"
+import { useState, useEffect, useMemo } from "react"
+import { useForm } from "react-hook-form"
+import { zodResolver } from "@hookform/resolvers/zod"
+import * as z from "zod"
+import { format } from "date-fns"
+import { toast } from "sonner"
+import { MessageSquare, Paperclip } from "lucide-react"
+
+import { Button } from "@/components/ui/button"
+import {
+ Form,
+ FormControl,
+ FormDescription,
+ FormField,
+ FormItem,
+ FormLabel,
+ FormMessage,
+} from "@/components/ui/form"
+import { Input } from "@/components/ui/input"
+import { Textarea } from "@/components/ui/textarea"
+import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
+import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from "@/components/ui/card"
+import { Badge } from "@/components/ui/badge"
+import { Separator } from "@/components/ui/separator"
+import { ScrollArea } from "@/components/ui/scroll-area"
+import { DatePicker } from "@/components/ui/date-picker"
+import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
+import { ClientDataTable } from "@/components/client-data-table/data-table"
+import { Skeleton } from "@/components/ui/skeleton"
+
+import { formatCurrency, formatDate } from "@/lib/utils"
+import { QuotationItemEditor } from "./quotation-item-editor"
+import {
+ submitVendorQuotation,
+ updateVendorQuotation,
+ fetchCurrencies,
+ fetchPaymentTerms,
+ fetchIncoterms,
+ fetchBuyerVendorComments,
+ Comment
+} from "../services"
+import { BuyerCommunicationDrawer } from "./buyer-communication-drawer"
+
+// 견적서 폼 스키마
+const quotationFormSchema = z.object({
+ quotationVersion: z.number().min(1),
+ // 필수값 표시됨
+ currency: z.string().min(1, "통화를 선택해주세요"),
+ // 필수값 표시됨
+ validUntil: z.date({
+ required_error: "견적 유효기간을 선택해주세요",
+ invalid_type_error: "유효한 날짜를 선택해주세요",
+ }),
+ // 필수값 표시됨
+ estimatedDeliveryDate: z.date({
+ required_error: "예상 납품일을 선택해주세요",
+ invalid_type_error: "유효한 날짜를 선택해주세요",
+ }),
+ // 필수값 표시됨
+ paymentTermsCode: z.string({
+ required_error: "지불 조건을 선택해주세요",
+ }).min(1, "지불 조건을 선택해주세요"),
+ // 필수값 표시됨
+ incotermsCode: z.string({
+ required_error: "인코텀즈를 선택해주세요",
+ }).min(1, "인코텀즈를 선택해주세요"),
+ // 필수값 아님
+ incotermsDetail: z.string().optional(),
+ // 필수값 아님
+ remark: z.string().optional(),
+})
+
+type QuotationFormValues = z.infer<typeof quotationFormSchema>
+
+// 데이터 타입 정의
+interface Currency {
+ code: string
+ name: string
+}
+
+interface PaymentTerm {
+ code: string
+ description: string
+}
+
+interface Incoterm {
+ code: string
+ description: string
+}
+
+// 이 컴포넌트에 전달되는 견적서 데이터 타입
+interface VendorQuotation {
+ id: number
+ rfqId: number
+ vendorId: number
+ quotationCode: string | null
+ quotationVersion: number | null
+ totalItemsCount: number | null
+ subTotal: string| null
+ taxTotal: string| null
+ discountTotal: string| null
+ totalPrice: string| null
+ currency: string| null
+ validUntil: Date | null
+ estimatedDeliveryDate: Date | null
+ paymentTermsCode: string | null
+ incotermsCode: string | null
+ incotermsDetail: string | 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
+ // 기타 필요한 정보
+ }
+ vendor: {
+ id: number
+ vendorName: string
+ vendorCode: string| null
+ // 기타 필요한 정보
+ }
+ items: QuotationItem[]
+}
+
+// 견적 아이템 타입
+interface QuotationItem {
+ id: number
+ quotationId: number
+ prItemId: number
+ materialCode: string | null
+ materialDescription: string | null
+ quantity: number
+ uom: string | null
+ unitPrice: number
+ totalPrice: number
+ currency: string
+ vendorMaterialCode: string | null
+ vendorMaterialDescription: string | null
+ deliveryDate: Date | null
+ leadTimeInDays: number | null
+ taxRate: number | null
+ taxAmount: number | null
+ discountRate: number | null
+ discountAmount: number | null
+ remark: string | null
+ isAlternative: boolean
+ isRecommended: boolean
+ createdAt: Date
+ updatedAt: Date
+ prItem?: {
+ id: number
+ materialCode: string | null
+ materialDescription: string | null
+ // 기타 필요한 정보
+ }
+}
+
+// 견적서 편집 컴포넌트 프롭스
+interface VendorQuotationEditorProps {
+ quotation: VendorQuotation
+}
+
+export default function VendorQuotationEditor({ quotation }: VendorQuotationEditorProps) {
+
+
+ const [activeTab, setActiveTab] = useState("items")
+ const [isSubmitting, setIsSubmitting] = useState(false)
+ const [isSaving, setIsSaving] = useState(false)
+ const [items, setItems] = useState<QuotationItem[]>(quotation.items || [])
+
+ // 서버에서 가져온 데이터 상태
+ const [currencies, setCurrencies] = useState<Currency[]>([])
+ const [paymentTerms, setPaymentTerms] = useState<PaymentTerm[]>([])
+ const [incoterms, setIncoterms] = useState<Incoterm[]>([])
+
+ // 데이터 로딩 상태
+ const [loadingCurrencies, setLoadingCurrencies] = useState(true)
+ const [loadingPaymentTerms, setLoadingPaymentTerms] = useState(true)
+ const [loadingIncoterms, setLoadingIncoterms] = useState(true)
+
+ // 커뮤니케이션 드로어 상태
+ const [communicationDrawerOpen, setCommunicationDrawerOpen] = useState(false)
+
+ const [comments, setComments] = useState<Comment[]>([]);
+ const [unreadCount, setUnreadCount] = useState(0);
+ const [loadingComments, setLoadingComments] = useState(false);
+
+ // 컴포넌트 마운트 시 메시지 미리 로드
+ useEffect(() => {
+ if (quotation) {
+ loadCommunicationData();
+ }
+ }, [quotation]);
+
+ // 메시지 데이터 로드 함수
+ const loadCommunicationData = async () => {
+ try {
+ setLoadingComments(true);
+ const commentsData = await fetchBuyerVendorComments(quotation.rfqId, quotation.vendorId);
+ setComments(commentsData);
+
+ // 읽지 않은 메시지 수 계산
+ const unread = commentsData.filter(
+ comment => !comment.isVendorComment && !comment.isRead
+ ).length;
+ setUnreadCount(unread);
+ } catch (error) {
+ console.error("메시지 데이터 로드 오류:", error);
+ } finally {
+ setLoadingComments(false);
+ }
+ };
+
+ // 커뮤니케이션 드로어가 닫힐 때 데이터 새로고침
+ const handleCommunicationDrawerChange = (open: boolean) => {
+ setCommunicationDrawerOpen(open);
+ if (!open) {
+ loadCommunicationData(); // 드로어가 닫힐 때 데이터 새로고침
+ }
+ };
+
+ // 버튼 비활성화
+ const isBeforeDueDate = () => {
+ if (!quotation.rfq.dueDate) {
+ // dueDate가 null인 경우 기본적으로 수정 불가능하도록 설정 (false 반환)
+ return false;
+ }
+
+ const now = new Date();
+ const dueDate = new Date(quotation.rfq.dueDate);
+ return now < dueDate;
+ };
+ // 수정된 isDisabled 조건
+ const isDisabled = (quotation.status === "Accepted") ||
+ ((quotation.status === "Submitted" || quotation.status === "Revised") &&
+ !isBeforeDueDate());
+
+
+ // 견적서 총합 계산
+ const totals = useMemo(() => {
+ const subTotal = items.reduce((sum, item) => sum + Number(item.totalPrice), 0)
+ const taxTotal = items.reduce((sum, item) => sum + (Number(item.taxAmount) || 0), 0)
+ const discountTotal = items.reduce((sum, item) => sum + (Number(item.discountAmount) || 0), 0)
+ const totalPrice = subTotal + taxTotal - discountTotal
+
+ return {
+ subTotal,
+ taxTotal,
+ discountTotal,
+ totalPrice
+ }
+ }, [items])
+
+ // 폼 설정
+ const form = useForm<QuotationFormValues>({
+ resolver: zodResolver(quotationFormSchema),
+ defaultValues: {
+ quotationVersion: quotation.quotationVersion || 0,
+ currency: quotation.currency || "KRW",
+ validUntil: quotation.validUntil || undefined,
+ estimatedDeliveryDate: quotation.estimatedDeliveryDate || undefined,
+ paymentTermsCode: quotation.paymentTermsCode || "",
+ incotermsCode: quotation.incotermsCode || "",
+ incotermsDetail: quotation.incotermsDetail || "",
+ remark: quotation.remark || "",
+ },
+ mode: "onChange", // 실시간 검증 활성화
+ })
+
+ // 마운트 시 데이터 로드
+ useEffect(() => {
+ // 통화 데이터 로드
+ const loadCurrencies = async () => {
+ try {
+ setLoadingCurrencies(true)
+ const result = await fetchCurrencies()
+ if (result.success) {
+ setCurrencies(result.data)
+ } else {
+ toast.error(result.message || "통화 데이터 로드 실패")
+ }
+ } catch (error) {
+ console.error("통화 데이터 로드 오류:", error)
+ toast.error("통화 데이터를 불러오는 중 오류가 발생했습니다")
+ } finally {
+ setLoadingCurrencies(false)
+ }
+ }
+
+ // 지불 조건 데이터 로드
+ const loadPaymentTerms = async () => {
+ try {
+ setLoadingPaymentTerms(true)
+ const result = await fetchPaymentTerms()
+ if (result.success) {
+ setPaymentTerms(result.data)
+ } else {
+ toast.error(result.message || "지불 조건 데이터 로드 실패")
+ }
+ } catch (error) {
+ console.error("지불 조건 데이터 로드 오류:", error)
+ toast.error("지불 조건 데이터를 불러오는 중 오류가 발생했습니다")
+ } finally {
+ setLoadingPaymentTerms(false)
+ }
+ }
+
+ // 인코텀즈 데이터 로드
+ const loadIncoterms = async () => {
+ try {
+ setLoadingIncoterms(true)
+ const result = await fetchIncoterms()
+ if (result.success) {
+ setIncoterms(result.data)
+ } else {
+ toast.error(result.message || "인코텀즈 데이터 로드 실패")
+ }
+ } catch (error) {
+ console.error("인코텀즈 데이터 로드 오류:", error)
+ toast.error("인코텀즈 데이터를 불러오는 중 오류가 발생했습니다")
+ } finally {
+ setLoadingIncoterms(false)
+ }
+ }
+
+ // 함수 호출
+ loadCurrencies()
+ loadPaymentTerms()
+ loadIncoterms()
+ }, [])
+
+ // 견적서 저장
+ const handleSave = async () => {
+ try {
+ setIsSaving(true)
+
+ // 기본 검증 (통화는 필수)
+ const validationResult = await form.trigger(['currency']);
+ if (!validationResult) {
+ toast.warning("통화는 필수 항목입니다");
+ return;
+ }
+
+ const values = form.getValues()
+
+ const result = await updateVendorQuotation({
+ id: quotation.id,
+ ...values,
+ subTotal: totals.subTotal.toString(),
+ taxTotal: totals.taxTotal.toString(),
+ discountTotal: totals.discountTotal.toString(),
+ totalPrice: totals.totalPrice.toString(),
+ totalItemsCount: items.length,
+ })
+
+ if (result.success) {
+ toast.success("견적서가 저장되었습니다")
+
+ // 견적서 제출 준비 상태 점검
+ const formValid = await form.trigger();
+ const itemsValid = !items.some(item => item.unitPrice <= 0 || !item.deliveryDate);
+ const alternativeItemsValid = !items.some(item =>
+ item.isAlternative && (!item.vendorMaterialDescription || !item.remark)
+ );
+
+ if (formValid && itemsValid && alternativeItemsValid) {
+ toast.info("모든 필수 정보가 입력되었습니다. '견적서 제출' 버튼을 클릭하여 제출하세요.");
+ } else {
+ const missingFields = [];
+ if (!formValid) missingFields.push("견적서 기본 정보");
+ if (!itemsValid) missingFields.push("견적 항목의 단가/납품일");
+ if (!alternativeItemsValid) missingFields.push("대체품 정보");
+
+ toast.info(`제출하기 전에 다음 정보를 입력해주세요: ${missingFields.join(', ')}`);
+ }
+ } else {
+ toast.error(result.message || "견적서 저장 중 오류가 발생했습니다")
+ }
+ } catch (error) {
+ console.error("견적서 저장 오류:", error)
+ toast.error("견적서 저장 중 오류가 발생했습니다")
+ } finally {
+ setIsSaving(false)
+ }
+ }
+
+ // 견적서 제출
+ const handleSubmit = async () => {
+ try {
+ setIsSubmitting(true)
+
+ // 1. 폼 스키마 검증 (기본 정보)
+ const formValid = await form.trigger();
+ if (!formValid) {
+ const formState = form.getFieldState("validUntil");
+ const estimatedDeliveryState = form.getFieldState("estimatedDeliveryDate");
+ const paymentTermsState = form.getFieldState("paymentTermsCode");
+ const incotermsState = form.getFieldState("incotermsCode");
+
+ // 주요 필드별 오류 메시지 표시
+ if (!form.getValues("validUntil")) {
+ toast.error("견적 유효기간을 선택해주세요");
+ } else if (!form.getValues("estimatedDeliveryDate")) {
+ toast.error("예상 납품일을 선택해주세요");
+ } else if (!form.getValues("paymentTermsCode")) {
+ toast.error("지불 조건을 선택해주세요");
+ } else if (!form.getValues("incotermsCode")) {
+ toast.error("인코텀즈를 선택해주세요");
+ } else {
+ toast.error("견적서 기본 정보를 모두 입력해주세요");
+ }
+
+ // 견적 정보 탭으로 이동
+ setActiveTab("details");
+ return;
+ }
+
+ // 2. 견적 항목 검증
+ const emptyItems = items.filter(item =>
+ item.unitPrice <= 0 || !item.deliveryDate
+ );
+
+ if (emptyItems.length > 0) {
+ toast.error(`${emptyItems.length}개 항목의 단가와 납품일을 입력해주세요`);
+ setActiveTab("items");
+ return;
+ }
+
+ // 3. 대체품 정보 검증
+ const invalidAlternativeItems = items.filter(item =>
+ item.isAlternative && (!item.vendorMaterialDescription || !item.remark)
+ );
+
+ if (invalidAlternativeItems.length > 0) {
+ toast.error(`${invalidAlternativeItems.length}개의 대체품 항목에 정보를 모두 입력해주세요`);
+ setActiveTab("items");
+ return;
+ }
+
+ // 모든 검증 통과 - 제출 진행
+ const values = form.getValues();
+
+ const result = await submitVendorQuotation({
+ id: quotation.id,
+ ...values,
+ subTotal: totals.subTotal.toString(),
+ taxTotal: totals.taxTotal.toString(),
+ discountTotal: totals.discountTotal.toString(),
+ totalPrice: totals.totalPrice.toString(),
+ totalItemsCount: items.length,
+ });
+
+ if (result.success && isBeforeDueDate()) {
+ toast.success("견적서가 제출되었습니다. 마감일 전까지 수정 가능합니다.");
+
+ // 페이지 새로고침
+ window.location.reload();
+ } else {
+ toast.error(result.message || "견적서 제출 중 오류가 발생했습니다");
+ }
+ } catch (error) {
+ console.error("견적서 제출 오류:", error);
+ toast.error("견적서 제출 중 오류가 발생했습니다");
+ } finally {
+ setIsSubmitting(false);
+ }
+ }
+
+ const isSubmitReady = () => {
+ // 폼 유효성
+ const formValid = !Object.keys(form.formState.errors).length;
+
+ // 항목 유효성
+ const itemsValid = !items.some(item =>
+ item.unitPrice <= 0 || !item.deliveryDate
+ );
+
+ // 대체품 유효성
+ const alternativeItemsValid = !items.some(item =>
+ item.isAlternative && (!item.vendorMaterialDescription || !item.remark)
+ );
+
+ // 유효하지 않은 항목 또는 대체품이 있으면 제출 불가
+ return formValid && itemsValid && alternativeItemsValid;
+ }
+
+ // 아이템 업데이트 핸들러
+ const handleItemsUpdate = (updatedItems: QuotationItem[]) => {
+ setItems(updatedItems)
+ }
+
+ // 상태에 따른 배지 색상
+ const getStatusBadge = (status: string) => {
+ switch (status) {
+ case "Draft":
+ return <Badge variant="outline">초안</Badge>
+ case "Submitted":
+ return <Badge variant="default">제출됨</Badge>
+ case "Revised":
+ return <Badge variant="secondary">수정됨</Badge>
+ case "Rejected":
+ return <Badge variant="destructive">반려됨</Badge>
+ case "Accepted":
+ return <Badge variant="default">승인됨</Badge>
+ default:
+ return <Badge>{status}</Badge>
+ }
+ }
+
+ // 셀렉트 로딩 상태 표시 컴포넌트
+ const SelectSkeleton = () => (
+ <div className="flex flex-col gap-2">
+ <Skeleton className="h-4 w-[40%]" />
+ <Skeleton className="h-10 w-full" />
+ </div>
+ )
+
+ return (
+ <div className="space-y-6">
+ <div className="flex justify-between items-start">
+ <div>
+ <h1 className="text-2xl font-bold tracking-tight">견적서 작성</h1>
+ <p className="text-muted-foreground">
+ RFQ 번호: {quotation.rfq.rfqCode} | 견적서 번호: {quotation.quotationCode}
+ </p>
+ {quotation.rfq.dueDate ? (
+ <p className={`text-sm ${isBeforeDueDate() ? 'text-green-600' : 'text-red-600'}`}>
+ 마감일: {formatDate(new Date(quotation.rfq.dueDate))}
+ {isBeforeDueDate()
+ ? ' (마감 전: 수정 가능)'
+ : ' (마감 됨: 수정 불가)'}
+ </p>
+ ) : (
+ <p className="text-sm text-amber-600">
+ 마감일이 설정되지 않았습니다
+ </p>
+ )}
+ </div>
+ <div className="flex items-center gap-2">
+ {getStatusBadge(quotation.status)}
+ {quotation.status === "Rejected" && (
+ <div className="text-sm text-destructive">
+ <span className="font-medium">반려 사유:</span> {quotation.rejectionReason || "사유 없음"}
+ </div>
+ )}
+ </div>
+ </div>
+
+ <Tabs value={activeTab} onValueChange={setActiveTab} className="w-full">
+ <TabsList>
+ <TabsTrigger value="items">견적 항목</TabsTrigger>
+ <TabsTrigger value="details">견적 정보</TabsTrigger>
+ <TabsTrigger value="communication">커뮤니케이션</TabsTrigger>
+ </TabsList>
+
+ {/* 견적 항목 탭 */}
+ <TabsContent value="items" className="p-0 pt-4">
+ <Card>
+ <CardHeader>
+ <CardTitle>견적 항목 정보</CardTitle>
+ <CardDescription>
+ 각 항목에 대한 가격, 납품일 등을 입력해주세요
+ </CardDescription>
+ </CardHeader>
+ <CardContent>
+ <QuotationItemEditor
+ items={items}
+ onItemsChange={handleItemsUpdate}
+ disabled={isDisabled}
+ currency={form.watch("currency")}
+ />
+ </CardContent>
+ <CardFooter className="flex justify-between border-t p-4">
+ <div className="space-y-1">
+ <div className="text-sm text-muted-foreground">
+ <span className="font-medium">소계:</span> {formatCurrency(totals.subTotal, quotation.currency)}
+ </div>
+ <div className="text-sm text-muted-foreground">
+ <span className="font-medium">세액:</span> {formatCurrency(totals.taxTotal, quotation.currency)}
+ </div>
+ <div className="text-sm text-muted-foreground">
+ <span className="font-medium">할인액:</span> {formatCurrency(totals.discountTotal, quotation.currency)}
+ </div>
+ <div className="text-base font-bold">
+ <span>총액:</span> {formatCurrency(totals.totalPrice, quotation.currency)}
+ </div>
+ </div>
+ <div className="flex space-x-2">
+ <Button
+ variant="outline"
+ onClick={handleSave}
+ disabled={isDisabled || isSaving}
+ >
+ {isSaving ? "저장 중..." : "저장"}
+ </Button>
+ <Button
+ onClick={handleSubmit}
+ disabled={isDisabled || isSubmitting || !isSubmitReady()}
+ >
+ {isSubmitting ? "제출 중..." : "견적서 제출"}
+ </Button>
+ </div>
+ </CardFooter>
+ </Card>
+ </TabsContent>
+
+ {/* 견적 정보 탭 */}
+ <TabsContent value="details" className="p-0 pt-4">
+ <Form {...form}>
+ <form className="space-y-6">
+ <Card>
+ <CardHeader>
+ <CardTitle>견적서 기본 정보</CardTitle>
+ <CardDescription>
+ 견적서의 일반 정보를 입력해주세요
+ </CardDescription>
+ </CardHeader>
+ <CardContent className="grid grid-cols-1 md:grid-cols-2 gap-6">
+ {/* 통화 필드 */}
+ {loadingCurrencies ? (
+ <SelectSkeleton />
+ ) : (
+ <FormField
+ control={form.control}
+ name="currency"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel className="flex items-center">
+ 통화
+ <span className="text-destructive ml-1">*</span>
+ </FormLabel>
+ <Select
+ value={field.value}
+ onValueChange={field.onChange}
+ disabled={isDisabled}
+ >
+ <FormControl>
+ <SelectTrigger>
+ <SelectValue placeholder="통화 선택" />
+ </SelectTrigger>
+ </FormControl>
+ <SelectContent>
+ {currencies.map((currency) => (
+ <SelectItem key={currency.code} value={currency.code}>
+ {currency.code} ({currency.name})
+ </SelectItem>
+ ))}
+ </SelectContent>
+ </Select>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+ )}
+
+ <FormField
+ control={form.control}
+ name="validUntil"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel className="flex items-center">
+ 견적 유효기간
+ <span className="text-destructive ml-1">*</span> {/* 필수값 표시 */}
+ </FormLabel>
+ <FormControl>
+ <DatePicker
+ date={field.value}
+ onSelect={field.onChange}
+ disabled={isDisabled}
+ />
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+
+ <FormField
+ control={form.control}
+ name="estimatedDeliveryDate"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel className="flex items-center">
+ 예상 납품일
+ <span className="text-destructive ml-1">*</span>
+ </FormLabel>
+ <FormControl>
+ <DatePicker
+ date={field.value}
+ onSelect={field.onChange}
+ disabled={isDisabled}
+ />
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+
+ {/* 지불 조건 필드 */}
+ {loadingPaymentTerms ? (
+ <SelectSkeleton />
+ ) : (
+ <FormField
+ control={form.control}
+ name="paymentTermsCode"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel className="flex items-center">
+ 지불 조건
+ <span className="text-destructive ml-1">*</span>
+ </FormLabel>
+ <Select
+ value={field.value || ""}
+ onValueChange={field.onChange}
+ disabled={isDisabled}
+ >
+ <FormControl>
+ <SelectTrigger>
+ <SelectValue placeholder="지불 조건 선택" />
+ </SelectTrigger>
+ </FormControl>
+ <SelectContent>
+ {paymentTerms.map((term) => (
+ <SelectItem key={term.code} value={term.code}>
+ {term.description}
+ </SelectItem>
+ ))}
+ </SelectContent>
+ </Select>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+ )}
+
+ {/* 인코텀즈 필드 */}
+ {loadingIncoterms ? (
+ <SelectSkeleton />
+ ) : (
+ <FormField
+ control={form.control}
+ name="incotermsCode"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel className="flex items-center">
+ 인코텀즈
+ <span className="text-destructive ml-1">*</span>
+ </FormLabel>
+ <Select
+ value={field.value || ""}
+ onValueChange={field.onChange}
+ disabled={isDisabled}
+ >
+ <FormControl>
+ <SelectTrigger>
+ <SelectValue placeholder="인코텀즈 선택" />
+ </SelectTrigger>
+ </FormControl>
+ <SelectContent>
+ {incoterms.map((term) => (
+ <SelectItem key={term.code} value={term.code}>
+ {term.code} ({term.description})
+ </SelectItem>
+ ))}
+ </SelectContent>
+ </Select>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+ )}
+
+ <FormField
+ control={form.control}
+ name="incotermsDetail"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel className="flex items-center">
+ 인코텀즈 상세
+ <span className="text-destructive ml-1"></span>
+ </FormLabel>
+ <FormControl>
+ <Input
+ placeholder="인코텀즈 상세 정보 입력"
+ {...field}
+ value={field.value || ""}
+ disabled={isDisabled}
+ />
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+
+ <FormField
+ control={form.control}
+ name="remark"
+ render={({ field }) => (
+ <FormItem className="col-span-2">
+ <FormLabel className="flex items-center">
+ 비고
+ <span className="text-destructive ml-1"></span>
+ </FormLabel>
+ <FormControl>
+ <Textarea
+ placeholder="추가 정보나 특이사항을 입력해주세요"
+ className="resize-none min-h-[100px]"
+ {...field}
+ value={field.value || ""}
+ disabled={isDisabled}
+ />
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+ </CardContent>
+ <CardFooter className="flex justify-end">
+ <Button
+ variant="outline"
+ onClick={handleSave}
+ disabled={isDisabled || isSaving}
+ >
+ {isSaving ? "저장 중..." : "저장"}
+ </Button>
+ </CardFooter>
+ </Card>
+ </form>
+ </Form>
+ </TabsContent>
+
+ {/* 커뮤니케이션 탭 */}
+ <TabsContent value="communication" className="p-0 pt-4">
+ <Card>
+ <CardHeader className="flex flex-row items-center justify-between">
+ <div>
+ <CardTitle className="flex items-center gap-2">
+ 커뮤니케이션
+ {unreadCount > 0 && (
+ <Badge variant="destructive" className="ml-2">
+ 새 메시지 {unreadCount}
+ </Badge>
+ )}
+ </CardTitle>
+ <CardDescription>
+ 구매자와의 메시지 및 첨부파일
+ </CardDescription>
+ </div>
+ <Button
+ onClick={() => setCommunicationDrawerOpen(true)}
+ variant="outline"
+ size="sm"
+ >
+ <MessageSquare className="h-4 w-4 mr-2" />
+ {unreadCount > 0 ? "새 메시지 확인" : "메시지 보내기"}
+ </Button>
+ </CardHeader>
+ <CardContent>
+ {loadingComments ? (
+ <div className="flex items-center justify-center p-8">
+ <div className="text-center">
+ <Skeleton className="h-4 w-32 mx-auto mb-2" />
+ <Skeleton className="h-4 w-48 mx-auto" />
+ </div>
+ </div>
+ ) : comments.length === 0 ? (
+ <div className="min-h-[200px] flex flex-col items-center justify-center text-center p-8">
+ <div className="max-w-md">
+ <div className="mx-auto bg-primary/10 rounded-full w-12 h-12 flex items-center justify-center mb-4">
+ <MessageSquare className="h-6 w-6 text-primary" />
+ </div>
+ <h3 className="text-lg font-medium mb-2">아직 메시지가 없습니다</h3>
+ <p className="text-muted-foreground mb-4">
+ 견적서에 대한 질문이나 의견이 있으신가요? 구매자와 메시지를 주고받으세요.
+ </p>
+ <Button
+ onClick={() => setCommunicationDrawerOpen(true)}
+ className="mx-auto"
+ >
+ 메시지 보내기
+ </Button>
+ </div>
+ </div>
+ ) : (
+ <div className="space-y-4">
+ {/* 최근 메시지 3개 미리보기 */}
+ <div className="space-y-2">
+ <h3 className="text-sm font-medium">최근 메시지</h3>
+ <ScrollArea className="h-[250px] rounded-md border p-4">
+ {comments.slice(-3).map(comment => (
+ <div
+ key={comment.id}
+ className={`p-3 mb-3 rounded-lg ${!comment.isVendorComment && !comment.isRead
+ ? 'bg-primary/10 border-l-4 border-primary'
+ : 'bg-muted/50'
+ }`}
+ >
+ <div className="flex justify-between items-center mb-1">
+ <span className="text-sm font-medium">
+ {comment.isVendorComment
+ ? '나'
+ : comment.userName || '구매 담당자'}
+ </span>
+ <span className="text-xs text-muted-foreground">
+ {new Date(comment.createdAt).toLocaleDateString()}
+ </span>
+ </div>
+ <p className="text-sm line-clamp-2">{comment.content}</p>
+ {comment.attachments.length > 0 && (
+ <div className="mt-1 text-xs text-muted-foreground">
+ <Paperclip className="h-3 w-3 inline mr-1" />
+ 첨부파일 {comment.attachments.length}개
+ </div>
+ )}
+ </div>
+ ))}
+ </ScrollArea>
+ </div>
+
+ <div className="flex justify-center">
+ <Button
+ onClick={() => setCommunicationDrawerOpen(true)}
+ className="w-full"
+ >
+ 전체 메시지 보기 ({comments.length}개)
+ </Button>
+ </div>
+ </div>
+ )}
+ </CardContent>
+ </Card>
+
+ {/* 커뮤니케이션 드로어 */}
+ <BuyerCommunicationDrawer
+ open={communicationDrawerOpen}
+ onOpenChange={handleCommunicationDrawerChange}
+ quotation={quotation}
+ onSuccess={loadCommunicationData}
+ />
+ </TabsContent>
+ </Tabs>
+ </div>
+ )
+} \ No newline at end of file