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