summaryrefslogtreecommitdiff
path: root/lib/procurement-rfqs/table/detail-table
diff options
context:
space:
mode:
Diffstat (limited to 'lib/procurement-rfqs/table/detail-table')
-rw-r--r--lib/procurement-rfqs/table/detail-table/add-vendor-dialog.tsx512
-rw-r--r--lib/procurement-rfqs/table/detail-table/delete-vendor-dialog.tsx150
-rw-r--r--lib/procurement-rfqs/table/detail-table/rfq-detail-column.tsx369
-rw-r--r--lib/procurement-rfqs/table/detail-table/rfq-detail-table.tsx521
-rw-r--r--lib/procurement-rfqs/table/detail-table/update-vendor-sheet.tsx449
-rw-r--r--lib/procurement-rfqs/table/detail-table/vendor-communication-drawer.tsx518
-rw-r--r--lib/procurement-rfqs/table/detail-table/vendor-quotation-comparison-dialog.tsx665
7 files changed, 3184 insertions, 0 deletions
diff --git a/lib/procurement-rfqs/table/detail-table/add-vendor-dialog.tsx b/lib/procurement-rfqs/table/detail-table/add-vendor-dialog.tsx
new file mode 100644
index 00000000..79524f58
--- /dev/null
+++ b/lib/procurement-rfqs/table/detail-table/add-vendor-dialog.tsx
@@ -0,0 +1,512 @@
+"use client"
+
+import * as React from "react"
+import { useState } from "react"
+import { useForm } from "react-hook-form"
+import { zodResolver } from "@hookform/resolvers/zod"
+import { z } from "zod"
+import { toast } from "sonner"
+import { Check, ChevronsUpDown, File, Upload, X } from "lucide-react"
+
+import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog"
+import { Button } from "@/components/ui/button"
+import { Input } from "@/components/ui/input"
+import { Label } from "@/components/ui/label"
+import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from "@/components/ui/form"
+import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
+import { ProcurementRfqsView } from "@/db/schema"
+import { addVendorToRfq } from "@/lib/procurement-rfqs/services"
+import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from "@/components/ui/command"
+import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"
+import { cn } from "@/lib/utils"
+import { ScrollArea } from "@/components/ui/scroll-area"
+
+// 필수 필드를 위한 커스텀 레이블 컴포넌트
+const RequiredLabel = ({ children }: { children: React.ReactNode }) => (
+ <FormLabel>
+ {children} <span className="text-red-500">*</span>
+ </FormLabel>
+);
+
+// 폼 유효성 검증 스키마
+const vendorFormSchema = z.object({
+ vendorId: z.string().min(1, "벤더를 선택해주세요"),
+ currency: z.string().min(1, "통화를 선택해주세요"),
+ paymentTermsCode: z.string().min(1, "지불 조건을 선택해주세요"),
+ incotermsCode: z.string().min(1, "인코텀즈를 선택해주세요"),
+ incotermsDetail: z.string().optional(),
+ deliveryDate: z.string().optional(),
+ taxCode: z.string().optional(),
+ placeOfShipping: z.string().optional(),
+ placeOfDestination: z.string().optional(),
+ materialPriceRelatedYn: z.boolean().default(false),
+})
+
+type VendorFormValues = z.infer<typeof vendorFormSchema>
+
+interface AddVendorDialogProps {
+ open: boolean
+ onOpenChange: (open: boolean) => void
+ selectedRfq: ProcurementRfqsView | null
+ // 벤더 및 기타 옵션 데이터를 prop으로 받음
+ vendors?: { id: number; vendorName: string; vendorCode: string }[]
+ currencies?: { code: string; name: string }[]
+ paymentTerms?: { code: string; description: string }[]
+ incoterms?: { code: string; description: string }[]
+ onSuccess?: () => void
+ existingVendorIds?: number[]
+
+}
+
+export function AddVendorDialog({
+ open,
+ onOpenChange,
+ selectedRfq,
+ vendors = [],
+ currencies = [],
+ paymentTerms = [],
+ incoterms = [],
+ onSuccess,
+ existingVendorIds = [], // 기본값 빈 배열
+}: AddVendorDialogProps) {
+
+
+ const availableVendors = React.useMemo(() => {
+ return vendors.filter(vendor => !existingVendorIds.includes(vendor.id));
+ }, [vendors, existingVendorIds]);
+
+
+ // 파일 업로드 상태 관리
+ const [attachments, setAttachments] = useState<File[]>([])
+ const [isSubmitting, setIsSubmitting] = useState(false)
+
+ // 벤더 선택을 위한 팝오버 상태
+ const [vendorOpen, setVendorOpen] = useState(false)
+
+ const form = useForm<VendorFormValues>({
+ resolver: zodResolver(vendorFormSchema),
+ defaultValues: {
+ vendorId: "",
+ currency: "",
+ paymentTermsCode: "",
+ incotermsCode: "",
+ incotermsDetail: "",
+ deliveryDate: "",
+ taxCode: "",
+ placeOfShipping: "",
+ placeOfDestination: "",
+ materialPriceRelatedYn: false,
+ },
+ })
+
+ // 폼 제출 핸들러
+ async function onSubmit(values: VendorFormValues) {
+ if (!selectedRfq) {
+ toast.error("선택된 RFQ가 없습니다")
+ return
+ }
+
+ try {
+ setIsSubmitting(true)
+
+ // FormData 생성
+ const formData = new FormData()
+ formData.append("rfqId", selectedRfq.id.toString())
+
+ // 폼 데이터 추가
+ Object.entries(values).forEach(([key, value]) => {
+ formData.append(key, value.toString())
+ })
+
+ // 첨부파일 추가
+ attachments.forEach((file, index) => {
+ formData.append(`attachment-${index}`, file)
+ })
+
+ // 서버 액션 호출
+ const result = await addVendorToRfq(formData)
+
+ if (result.success) {
+ toast.success("벤더가 성공적으로 추가되었습니다")
+ onOpenChange(false)
+ form.reset()
+ setAttachments([])
+ onSuccess?.()
+ } else {
+ toast.error(result.message || "벤더 추가 중 오류가 발생했습니다")
+ }
+ } catch (error) {
+ console.error("벤더 추가 오류:", error)
+ toast.error("벤더 추가 중 오류가 발생했습니다")
+ } finally {
+ setIsSubmitting(false)
+ }
+ }
+
+ // 파일 업로드 핸들러
+ const handleFileUpload = (event: React.ChangeEvent<HTMLInputElement>) => {
+ if (event.target.files && event.target.files.length > 0) {
+ const newFiles = Array.from(event.target.files)
+ setAttachments((prev) => [...prev, ...newFiles])
+ }
+ }
+
+ // 파일 삭제 핸들러
+ const handleRemoveFile = (index: number) => {
+ setAttachments((prev) => prev.filter((_, i) => i !== index))
+ }
+
+ return (
+ <Dialog open={open} onOpenChange={onOpenChange}>
+ {/* 커스텀 DialogContent - 고정 헤더, 스크롤 가능한 콘텐츠, 고정 푸터 */}
+ <DialogContent className="sm:max-w-[600px] p-0 h-[85vh] flex flex-col overflow-hidden" style={{maxHeight:'85vh'}}>
+ {/* 고정 헤더 */}
+ <div className="p-6 border-b">
+ <DialogHeader>
+ <DialogTitle>벤더 추가</DialogTitle>
+ <DialogDescription>
+ {selectedRfq ? (
+ <>
+ <span className="font-medium">{selectedRfq.rfqCode}</span> RFQ에 벤더를 추가합니다.
+ </>
+ ) : (
+ "RFQ에 벤더를 추가합니다."
+ )}
+ </DialogDescription>
+ </DialogHeader>
+ </div>
+
+ {/* 스크롤 가능한 콘텐츠 영역 */}
+ <div className="flex-1 overflow-y-auto p-6">
+ <Form {...form}>
+ <form id="vendor-form" onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
+ {/* 검색 가능한 벤더 선택 필드 */}
+ <FormField
+ control={form.control}
+ name="vendorId"
+ render={({ field }) => (
+ <FormItem className="flex flex-col">
+ <RequiredLabel>벤더</RequiredLabel>
+ <Popover open={vendorOpen} onOpenChange={setVendorOpen}>
+ <PopoverTrigger asChild>
+ <FormControl>
+ <Button
+ variant="outline"
+ role="combobox"
+ aria-expanded={vendorOpen}
+ className={cn(
+ "w-full justify-between",
+ !field.value && "text-muted-foreground"
+ )}
+ >
+ {field.value
+ ? vendors.find((vendor) => String(vendor.id) === field.value)
+ ? `${vendors.find((vendor) => String(vendor.id) === field.value)?.vendorName} (${vendors.find((vendor) => String(vendor.id) === field.value)?.vendorCode})`
+ : "벤더를 선택하세요"
+ : "벤더를 선택하세요"}
+ <ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
+ </Button>
+ </FormControl>
+ </PopoverTrigger>
+ <PopoverContent className="w-[400px] p-0">
+ <Command>
+ <CommandInput placeholder="벤더 검색..." />
+ <CommandEmpty>검색 결과가 없습니다</CommandEmpty>
+ <CommandList>
+ <ScrollArea className="h-60">
+ <CommandGroup>
+ {availableVendors.length > 0 ? (
+ availableVendors.map((vendor) => (
+ <CommandItem
+ key={vendor.id}
+ value={`${vendor.vendorName} ${vendor.vendorCode}`}
+ onSelect={() => {
+ form.setValue("vendorId", String(vendor.id), {
+ shouldValidate: true,
+ })
+ setVendorOpen(false)
+ }}
+ >
+ <Check
+ className={cn(
+ "mr-2 h-4 w-4",
+ String(vendor.id) === field.value
+ ? "opacity-100"
+ : "opacity-0"
+ )}
+ />
+ {vendor.vendorName} ({vendor.vendorCode})
+ </CommandItem>
+ ))
+ ) : (
+ <CommandItem disabled>추가 가능한 벤더가 없습니다</CommandItem>
+ )}
+ </CommandGroup>
+ </ScrollArea>
+ </CommandList>
+ </Command>
+ </PopoverContent>
+ </Popover>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+
+ <FormField
+ control={form.control}
+ name="currency"
+ render={({ field }) => (
+ <FormItem>
+ <RequiredLabel>통화</RequiredLabel>
+ <Select onValueChange={field.onChange} defaultValue={field.value}>
+ <FormControl>
+ <SelectTrigger>
+ <SelectValue placeholder="통화를 선택하세요" />
+ </SelectTrigger>
+ </FormControl>
+ <SelectContent>
+ {currencies.map((currency) => (
+ <SelectItem key={currency.code} value={currency.code}>
+ {currency.name} ({currency.code})
+ </SelectItem>
+ ))}
+ </SelectContent>
+ </Select>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+
+ <div className="grid grid-cols-2 gap-4">
+ <FormField
+ control={form.control}
+ name="paymentTermsCode"
+ render={({ field }) => (
+ <FormItem>
+ <RequiredLabel>지불 조건</RequiredLabel>
+ <Select onValueChange={field.onChange} defaultValue={field.value}>
+ <FormControl>
+ <SelectTrigger>
+ <SelectValue placeholder="지불 조건 선택" />
+ </SelectTrigger>
+ </FormControl>
+ <SelectContent>
+ {paymentTerms.map((term) => (
+ <SelectItem key={term.code} value={term.code}>
+ {term.description}
+ </SelectItem>
+ ))}
+ </SelectContent>
+ </Select>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+
+ <FormField
+ control={form.control}
+ name="incotermsCode"
+ render={({ field }) => (
+ <FormItem>
+ <RequiredLabel>인코텀즈</RequiredLabel>
+ <Select onValueChange={field.onChange} defaultValue={field.value}>
+ <FormControl>
+ <SelectTrigger>
+ <SelectValue placeholder="인코텀즈 선택" />
+ </SelectTrigger>
+ </FormControl>
+ <SelectContent>
+ {incoterms.map((incoterm) => (
+ <SelectItem key={incoterm.code} value={incoterm.code}>
+ {incoterm.description}
+ </SelectItem>
+ ))}
+ </SelectContent>
+ </Select>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+ </div>
+
+ {/* 나머지 필드들은 동일하게 유지 */}
+ <FormField
+ control={form.control}
+ name="incotermsDetail"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>인코텀즈 세부사항</FormLabel>
+ <FormControl>
+ <Input {...field} placeholder="인코텀즈 세부사항" />
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+
+ <div className="grid grid-cols-2 gap-4">
+ <FormField
+ control={form.control}
+ name="deliveryDate"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>납품 예정일</FormLabel>
+ <FormControl>
+ <Input {...field} type="date" />
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+
+ <FormField
+ control={form.control}
+ name="taxCode"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>세금 코드</FormLabel>
+ <FormControl>
+ <Input {...field} placeholder="세금 코드" />
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+ </div>
+
+ <div className="grid grid-cols-2 gap-4">
+ <FormField
+ control={form.control}
+ name="placeOfShipping"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>선적지</FormLabel>
+ <FormControl>
+ <Input {...field} placeholder="선적지" />
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+
+ <FormField
+ control={form.control}
+ name="placeOfDestination"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>도착지</FormLabel>
+ <FormControl>
+ <Input {...field} placeholder="도착지" />
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+ </div>
+
+ <FormField
+ control={form.control}
+ name="materialPriceRelatedYn"
+ render={({ field }) => (
+ <FormItem className="flex flex-row items-start space-x-3 space-y-0 rounded-md border p-4">
+ <FormControl>
+ <input
+ type="checkbox"
+ checked={field.value}
+ onChange={field.onChange}
+ className="h-4 w-4 rounded border-gray-300 text-indigo-600 focus:ring-indigo-500"
+ />
+ </FormControl>
+ <div className="space-y-1 leading-none">
+ <FormLabel>하도급대금 연동제 여부</FormLabel>
+ </div>
+ </FormItem>
+ )}
+ />
+
+ {/* 파일 업로드 섹션 */}
+ <div className="space-y-2">
+ <Label>첨부 파일</Label>
+ <div className="border rounded-md p-4">
+ <div className="flex items-center justify-center w-full">
+ <label
+ htmlFor="file-upload"
+ className="flex flex-col items-center justify-center w-full h-32 border-2 border-dashed rounded-lg cursor-pointer bg-gray-50 hover:bg-gray-100"
+ >
+ <div className="flex flex-col items-center justify-center pt-5 pb-6">
+ <Upload className="w-8 h-8 mb-2 text-gray-500" />
+ <p className="mb-2 text-sm text-gray-500">
+ <span className="font-semibold">클릭하여 파일 업로드</span> 또는 파일을 끌어 놓으세요
+ </p>
+ <p className="text-xs text-gray-500">PDF, DOCX, XLSX, JPG, PNG (최대 10MB)</p>
+ </div>
+ <input
+ id="file-upload"
+ type="file"
+ className="hidden"
+ multiple
+ onChange={handleFileUpload}
+ />
+ </label>
+ </div>
+
+ {/* 업로드된 파일 목록 */}
+ {attachments.length > 0 && (
+ <div className="mt-4 space-y-2">
+ <h4 className="text-sm font-medium">업로드된 파일</h4>
+ <ul className="space-y-2">
+ {attachments.map((file, index) => (
+ <li
+ key={index}
+ className="flex items-center justify-between p-2 text-sm bg-gray-50 rounded-md"
+ >
+ <div className="flex items-center space-x-2">
+ <File className="w-4 h-4 text-gray-500" />
+ <span className="truncate max-w-[250px]">{file.name}</span>
+ <span className="text-gray-500 text-xs">
+ ({(file.size / 1024).toFixed(1)} KB)
+ </span>
+ </div>
+ <Button
+ type="button"
+ variant="ghost"
+ size="sm"
+ onClick={() => handleRemoveFile(index)}
+ >
+ <X className="w-4 h-4 text-gray-500" />
+ </Button>
+ </li>
+ ))}
+ </ul>
+ </div>
+ )}
+ </div>
+ </div>
+ </form>
+ </Form>
+ </div>
+
+ {/* 고정 푸터 */}
+ <div className="p-6 border-t">
+ <DialogFooter>
+ <Button
+ type="button"
+ variant="outline"
+ onClick={() => onOpenChange(false)}
+ disabled={isSubmitting}
+ >
+ 취소
+ </Button>
+ <Button
+ type="submit"
+ form="vendor-form"
+ disabled={isSubmitting}
+ >
+ {isSubmitting ? "처리 중..." : "벤더 추가"}
+ </Button>
+ </DialogFooter>
+ </div>
+ </DialogContent>
+ </Dialog>
+ )
+} \ No newline at end of file
diff --git a/lib/procurement-rfqs/table/detail-table/delete-vendor-dialog.tsx b/lib/procurement-rfqs/table/detail-table/delete-vendor-dialog.tsx
new file mode 100644
index 00000000..49d982e1
--- /dev/null
+++ b/lib/procurement-rfqs/table/detail-table/delete-vendor-dialog.tsx
@@ -0,0 +1,150 @@
+"use client"
+
+import * as React from "react"
+import { type RfqDetailView } from "./rfq-detail-column"
+import { type Row } from "@tanstack/react-table"
+import { Loader, Trash } from "lucide-react"
+import { toast } from "sonner"
+
+import { useMediaQuery } from "@/hooks/use-media-query"
+import { Button } from "@/components/ui/button"
+import {
+ Dialog,
+ DialogClose,
+ DialogContent,
+ DialogDescription,
+ DialogFooter,
+ DialogHeader,
+ DialogTitle,
+ DialogTrigger,
+} from "@/components/ui/dialog"
+import {
+ Drawer,
+ DrawerClose,
+ DrawerContent,
+ DrawerDescription,
+ DrawerFooter,
+ DrawerHeader,
+ DrawerTitle,
+ DrawerTrigger,
+} from "@/components/ui/drawer"
+import { deleteRfqDetail } from "@/lib/procurement-rfqs/services"
+
+
+interface DeleteRfqDetailDialogProps
+ extends React.ComponentPropsWithoutRef<typeof Dialog> {
+ detail: RfqDetailView | null
+ showTrigger?: boolean
+ onSuccess?: () => void
+}
+
+export function DeleteRfqDetailDialog({
+ detail,
+ showTrigger = true,
+ onSuccess,
+ ...props
+}: DeleteRfqDetailDialogProps) {
+ const [isDeletePending, startDeleteTransition] = React.useTransition()
+ const isDesktop = useMediaQuery("(min-width: 640px)")
+
+ function onDelete() {
+ if (!detail) return
+
+ startDeleteTransition(async () => {
+ try {
+ const result = await deleteRfqDetail(detail.detailId)
+
+ if (!result.success) {
+ toast.error(result.message || "삭제 중 오류가 발생했습니다")
+ return
+ }
+
+ props.onOpenChange?.(false)
+ toast.success("RFQ 벤더 정보가 삭제되었습니다")
+ onSuccess?.()
+ } catch (error) {
+ console.error("RFQ 벤더 삭제 오류:", error)
+ toast.error("삭제 중 오류가 발생했습니다")
+ }
+ })
+ }
+
+ if (isDesktop) {
+ return (
+ <Dialog {...props}>
+ {showTrigger ? (
+ <DialogTrigger asChild>
+ <Button variant="destructive" size="sm">
+ <Trash className="mr-2 size-4" aria-hidden="true" />
+ 삭제
+ </Button>
+ </DialogTrigger>
+ ) : null}
+ <DialogContent>
+ <DialogHeader>
+ <DialogTitle>정말로 삭제하시겠습니까?</DialogTitle>
+ <DialogDescription>
+ 이 작업은 되돌릴 수 없습니다. 벤더 &quot;{detail?.vendorName}&quot;({detail?.vendorCode})의 RFQ 정보가 영구적으로 삭제됩니다.
+ </DialogDescription>
+ </DialogHeader>
+ <DialogFooter className="gap-2 sm:space-x-0">
+ <DialogClose asChild>
+ <Button variant="outline">취소</Button>
+ </DialogClose>
+ <Button
+ aria-label="선택한 RFQ 벤더 정보 삭제"
+ variant="destructive"
+ onClick={onDelete}
+ disabled={isDeletePending}
+ >
+ {isDeletePending && (
+ <Loader
+ className="mr-2 size-4 animate-spin"
+ aria-hidden="true"
+ />
+ )}
+ 삭제
+ </Button>
+ </DialogFooter>
+ </DialogContent>
+ </Dialog>
+ )
+ }
+
+ return (
+ <Drawer {...props}>
+ {showTrigger ? (
+ <DrawerTrigger asChild>
+ <Button variant="destructive" size="sm">
+ <Trash className="mr-2 size-4" aria-hidden="true" />
+ 삭제
+ </Button>
+ </DrawerTrigger>
+ ) : null}
+ <DrawerContent>
+ <DrawerHeader>
+ <DrawerTitle>정말로 삭제하시겠습니까?</DrawerTitle>
+ <DrawerDescription>
+ 이 작업은 되돌릴 수 없습니다. 벤더 &quot;{detail?.vendorName}&quot;({detail?.vendorCode})의 RFQ 정보가 영구적으로 삭제됩니다.
+ </DrawerDescription>
+ </DrawerHeader>
+ <DrawerFooter className="gap-2 sm:space-x-0">
+ <DrawerClose asChild>
+ <Button variant="outline">취소</Button>
+ </DrawerClose>
+ <Button
+ aria-label="선택한 RFQ 벤더 정보 삭제"
+ variant="destructive"
+ onClick={onDelete}
+ disabled={isDeletePending}
+ >
+ {isDeletePending && (
+ <Loader className="mr-2 size-4 animate-spin" aria-hidden="true" />
+ )}
+ 삭제
+ </Button>
+ </DrawerFooter>
+ </DrawerContent>
+ </Drawer>
+ )
+} \ No newline at end of file
diff --git a/lib/procurement-rfqs/table/detail-table/rfq-detail-column.tsx b/lib/procurement-rfqs/table/detail-table/rfq-detail-column.tsx
new file mode 100644
index 00000000..ce5e7767
--- /dev/null
+++ b/lib/procurement-rfqs/table/detail-table/rfq-detail-column.tsx
@@ -0,0 +1,369 @@
+"use client"
+
+import * as React from "react"
+import type { ColumnDef, Row } from "@tanstack/react-table";
+import { formatDate, formatDateTime } from "@/lib/utils"
+import { DataTableColumnHeaderSimple } from "@/components/data-table/data-table-column-simple-header"
+import {
+ DropdownMenu,
+ DropdownMenuContent,
+ DropdownMenuItem,
+ DropdownMenuRadioGroup,
+ DropdownMenuRadioItem,
+ DropdownMenuSeparator,
+ DropdownMenuShortcut,
+ DropdownMenuSub,
+ DropdownMenuSubContent,
+ DropdownMenuSubTrigger,
+ DropdownMenuTrigger,
+} from "@/components/ui/dropdown-menu";
+import { Ellipsis, MessageCircle } from "lucide-react";
+import { Button } from "@/components/ui/button";
+import { Badge } from "@/components/ui/badge";
+
+export interface DataTableRowAction<TData> {
+ row: Row<TData>;
+ type: "delete" | "update" | "communicate"; // communicate 타입 추가
+}
+
+// procurementRfqDetailsView 타입 정의 (DB 스키마에 맞게 조정 필요)
+export interface RfqDetailView {
+ detailId: number
+ rfqId: number
+ rfqCode: string
+ vendorId?: number | null // 벤더 ID 필드 추가
+ projectCode: string | null
+ projectName: string | null
+ vendorCountry: string | null
+ itemCode: string | null
+ itemName: string | null
+ vendorName: string | null
+ vendorCode: string | null
+ currency: string | null
+ paymentTermsCode: string | null
+ paymentTermsDescription: string | null
+ incotermsCode: string | null
+ incotermsDescription: string | null
+ incotermsDetail: string | null
+ deliveryDate: Date | null
+ taxCode: string | null
+ placeOfShipping: string | null
+ placeOfDestination: string | null
+ materialPriceRelatedYn: boolean | null
+ hasQuotation: boolean | null
+ updatedByUserName: string | null
+ quotationStatus: string | null
+ updatedAt: Date | null
+ prItemsCount: number
+ majorItemsCount: number
+ quotationVersion:number | null
+ // 커뮤니케이션 관련 필드 추가
+ commentCount?: number // 전체 코멘트 수
+ unreadCount?: number // 읽지 않은 코멘트 수
+ lastCommentDate?: Date // 마지막 코멘트 날짜
+}
+
+interface GetColumnsProps<TData> {
+ setRowAction: React.Dispatch<
+ React.SetStateAction<DataTableRowAction<TData> | null>
+ >;
+ unreadMessages?: Record<number, number>; // 벤더 ID별 읽지 않은 메시지 수
+}
+
+export function getRfqDetailColumns({
+ setRowAction,
+ unreadMessages = {},
+}: GetColumnsProps<RfqDetailView>): ColumnDef<RfqDetailView>[] {
+ return [
+ {
+ accessorKey: "quotationStatus",
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="견적 상태" />
+ ),
+ cell: ({ row }) => <div>{row.getValue("quotationStatus")}</div>,
+ meta: {
+ excelHeader: "견적 상태"
+ },
+ enableResizing: true,
+ size: 120,
+ },
+ {
+ accessorKey: "quotationVersion",
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="견적 버전" />
+ ),
+ cell: ({ row }) => <div>{row.getValue("quotationVersion")}</div>,
+ meta: {
+ excelHeader: "견적 버전"
+ },
+ enableResizing: true,
+ size: 120,
+ },
+ {
+ accessorKey: "vendorCode",
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="벤더 코드" />
+ ),
+ cell: ({ row }) => <div>{row.getValue("vendorCode")}</div>,
+ meta: {
+ excelHeader: "벤더 코드"
+ },
+ enableResizing: true,
+ size: 120,
+ },
+ {
+ accessorKey: "vendorName",
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="벤더명" />
+ ),
+ cell: ({ row }) => <div>{row.getValue("vendorName")}</div>,
+ meta: {
+ excelHeader: "벤더명"
+ },
+ enableResizing: true,
+ size: 160,
+ },
+ {
+ accessorKey: "vendorType",
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="내외자" />
+ ),
+ cell: ({ row }) => <div>{row.original.vendorCountry === "KR"?"D":"F"}</div>,
+ meta: {
+ excelHeader: "내외자"
+ },
+ enableResizing: true,
+ size: 80,
+ },
+ {
+ accessorKey: "currency",
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="통화" />
+ ),
+ cell: ({ row }) => <div>{row.getValue("currency")}</div>,
+ meta: {
+ excelHeader: "통화"
+ },
+ enableResizing: true,
+ size: 80,
+ },
+ {
+ accessorKey: "paymentTermsCode",
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="지불 조건 코드" />
+ ),
+ cell: ({ row }) => <div>{row.getValue("paymentTermsCode")}</div>,
+ meta: {
+ excelHeader: "지불 조건 코드"
+ },
+ enableResizing: true,
+ size: 140,
+ },
+ {
+ accessorKey: "paymentTermsDescription",
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="지불 조건" />
+ ),
+ cell: ({ row }) => <div>{row.getValue("paymentTermsDescription")}</div>,
+ meta: {
+ excelHeader: "지불 조건"
+ },
+ enableResizing: true,
+ size: 160,
+ },
+ {
+ accessorKey: "incotermsCode",
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="인코텀스 코드" />
+ ),
+ cell: ({ row }) => <div>{row.getValue("incotermsCode")}</div>,
+ meta: {
+ excelHeader: "인코텀스 코드"
+ },
+ enableResizing: true,
+ size: 140,
+ },
+ {
+ accessorKey: "incotermsDescription",
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="인코텀스" />
+ ),
+ cell: ({ row }) => <div>{row.getValue("incotermsDescription")}</div>,
+ meta: {
+ excelHeader: "인코텀스"
+ },
+ enableResizing: true,
+ size: 160,
+ },
+ {
+ accessorKey: "incotermsDetail",
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="인코텀스 상세" />
+ ),
+ cell: ({ row }) => <div>{row.getValue("incotermsDetail")}</div>,
+ meta: {
+ excelHeader: "인코텀스 상세"
+ },
+ enableResizing: true,
+ size: 160,
+ },
+ {
+ accessorKey: "deliveryDate",
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="납품일" />
+ ),
+ cell: ({ cell }) => {
+ const value = cell.getValue();
+ return value ? formatDate(value as Date, "KR") : "";
+ },
+ meta: {
+ excelHeader: "납품일"
+ },
+ enableResizing: true,
+ size: 120,
+ },
+ {
+ accessorKey: "taxCode",
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="세금 코드" />
+ ),
+ cell: ({ row }) => <div>{row.getValue("taxCode")}</div>,
+ meta: {
+ excelHeader: "세금 코드"
+ },
+ enableResizing: true,
+ size: 120,
+ },
+ {
+ accessorKey: "placeOfShipping",
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="선적지" />
+ ),
+ cell: ({ row }) => <div>{row.getValue("placeOfShipping")}</div>,
+ meta: {
+ excelHeader: "선적지"
+ },
+ enableResizing: true,
+ size: 140,
+ },
+ {
+ accessorKey: "placeOfDestination",
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="도착지" />
+ ),
+ cell: ({ row }) => <div>{row.getValue("placeOfDestination")}</div>,
+ meta: {
+ excelHeader: "도착지"
+ },
+ enableResizing: true,
+ size: 140,
+ },
+ {
+ accessorKey: "materialPriceRelatedYn",
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="하도급대금 연동" />
+ ),
+ cell: ({ row }) => <div>{row.getValue("materialPriceRelatedYn") ? "Y" : "N"}</div>,
+ meta: {
+ excelHeader: "하도급대금 연동"
+ },
+ enableResizing: true,
+ size: 140,
+ },
+ {
+ accessorKey: "updatedByUserName",
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="수정자" />
+ ),
+ cell: ({ row }) => <div>{row.getValue("updatedByUserName")}</div>,
+ meta: {
+ excelHeader: "수정자"
+ },
+ enableResizing: true,
+ size: 120,
+ },
+ {
+ accessorKey: "updatedAt",
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="수정일시" />
+ ),
+ cell: ({ cell }) => {
+ const value = cell.getValue();
+ return value ? formatDateTime(value as Date, "KR") : "";
+ },
+ meta: {
+ excelHeader: "수정일시"
+ },
+ enableResizing: true,
+ size: 140,
+ },
+ // 커뮤니케이션 컬럼 추가
+ {
+ id: "communication",
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="커뮤니케이션" />
+ ),
+ cell: ({ row }) => {
+ const vendorId = row.original.vendorId || 0;
+ const unreadCount = unreadMessages[vendorId] || 0;
+
+ return (
+ <Button
+ variant="ghost"
+ size="sm"
+ className="relative p-0 h-8 w-8 flex items-center justify-center"
+ onClick={() => setRowAction({ row, type: "communicate" })}
+ >
+ <MessageCircle className="h-4 w-4" />
+ {unreadCount > 0 && (
+ <Badge
+ variant="destructive"
+ className="absolute -top-1 -right-1 h-5 w-5 flex items-center justify-center p-0 text-xs"
+ >
+ {unreadCount}
+ </Badge>
+ )}
+ </Button>
+ );
+ },
+ enableResizing: false,
+ size: 80,
+ },
+ {
+ id: "actions",
+ enableHiding: false,
+ cell: function Cell({ row }) {
+ const [isUpdatePending, startUpdateTransition] = React.useTransition()
+
+ return (
+ <DropdownMenu>
+ <DropdownMenuTrigger asChild>
+ <Button
+ aria-label="Open menu"
+ variant="ghost"
+ className="flex size-7 p-0 data-[state=open]:bg-muted"
+ >
+ <Ellipsis className="size-4" aria-hidden="true" />
+ </Button>
+ </DropdownMenuTrigger>
+ <DropdownMenuContent align="end" className="w-40">
+ <DropdownMenuItem
+ onSelect={() => setRowAction({ row, type: "update" })}
+ >
+ Edit
+ </DropdownMenuItem>
+
+ <DropdownMenuItem
+ onSelect={() => setRowAction({ row, type: "delete" })}
+ >
+ Delete
+ <DropdownMenuShortcut>⌘⌫</DropdownMenuShortcut>
+ </DropdownMenuItem>
+ </DropdownMenuContent>
+ </DropdownMenu>
+ )
+ },
+ size: 40,
+ }
+ ]
+} \ No newline at end of file
diff --git a/lib/procurement-rfqs/table/detail-table/rfq-detail-table.tsx b/lib/procurement-rfqs/table/detail-table/rfq-detail-table.tsx
new file mode 100644
index 00000000..ad9a19e7
--- /dev/null
+++ b/lib/procurement-rfqs/table/detail-table/rfq-detail-table.tsx
@@ -0,0 +1,521 @@
+"use client"
+
+import * as React from "react"
+import { useEffect, useState } from "react"
+import {
+ DataTableRowAction,
+ getRfqDetailColumns,
+ RfqDetailView
+} from "./rfq-detail-column"
+import { toast } from "sonner"
+
+import { Skeleton } from "@/components/ui/skeleton"
+import { Card, CardContent } from "@/components/ui/card"
+import { Badge } from "@/components/ui/badge"
+import { ProcurementRfqsView } from "@/db/schema"
+import {
+ fetchCurrencies,
+ fetchIncoterms,
+ fetchPaymentTerms,
+ fetchRfqDetails,
+ fetchVendors,
+ fetchUnreadMessages
+} from "@/lib/procurement-rfqs/services"
+import { ClientDataTable } from "@/components/client-data-table/data-table"
+import { AddVendorDialog } from "./add-vendor-dialog"
+import { Button } from "@/components/ui/button"
+import { Loader2, UserPlus, BarChart2 } from "lucide-react" // 아이콘 추가
+import { DeleteRfqDetailDialog } from "./delete-vendor-dialog"
+import { UpdateRfqDetailSheet } from "./update-vendor-sheet"
+import { VendorCommunicationDrawer } from "./vendor-communication-drawer"
+import { VendorQuotationComparisonDialog } from "./vendor-quotation-comparison-dialog" // 새로운 컴포넌트 임포트
+
+// 프로퍼티 정의
+interface RfqDetailTablesProps {
+ selectedRfq: ProcurementRfqsView | null
+ maxHeight?: string | number
+}
+
+// 데이터 타입 정의
+interface Vendor {
+ id: number;
+ vendorName: string;
+ vendorCode: string | null; // Update this to allow null
+ // 기타 필요한 벤더 속성들
+}
+
+interface Currency {
+ code: string;
+ name: string;
+}
+
+interface PaymentTerm {
+ code: string;
+ description: string;
+}
+
+interface Incoterm {
+ code: string;
+ description: string;
+}
+
+export function RfqDetailTables({ selectedRfq , maxHeight}: RfqDetailTablesProps) {
+
+ console.log("selectedRfq", selectedRfq)
+ // 상태 관리
+ const [isLoading, setIsLoading] = useState(false)
+ const [isRefreshing, setIsRefreshing] = useState(false)
+ const [details, setDetails] = useState<RfqDetailView[]>([])
+ const [vendorDialogOpen, setVendorDialogOpen] = React.useState(false)
+ const [updateSheetOpen, setUpdateSheetOpen] = React.useState(false)
+ const [deleteDialogOpen, setDeleteDialogOpen] = React.useState(false)
+ const [selectedDetail, setSelectedDetail] = React.useState<RfqDetailView | null>(null)
+
+ const [vendors, setVendors] = React.useState<Vendor[]>([])
+ const [currencies, setCurrencies] = React.useState<Currency[]>([])
+ const [paymentTerms, setPaymentTerms] = React.useState<PaymentTerm[]>([])
+ const [incoterms, setIncoterms] = React.useState<Incoterm[]>([])
+ const [isAdddialogLoading, setIsAdddialogLoading] = useState(false)
+
+ const [rowAction, setRowAction] = React.useState<DataTableRowAction<RfqDetailView> | null>(null)
+
+ // 벤더 커뮤니케이션 상태 관리
+ const [communicationDrawerOpen, setCommunicationDrawerOpen] = useState(false)
+ const [selectedVendor, setSelectedVendor] = useState<RfqDetailView | null>(null)
+
+ // 읽지 않은 메시지 개수
+ const [unreadMessages, setUnreadMessages] = useState<Record<number, number>>({})
+ const [isUnreadLoading, setIsUnreadLoading] = useState(false)
+
+ // 견적 비교 다이얼로그 상태 관리 (추가)
+ const [comparisonDialogOpen, setComparisonDialogOpen] = useState(false)
+
+ const existingVendorIds = React.useMemo(() => {
+ return details.map(detail => Number(detail.vendorId)).filter(Boolean);
+ }, [details]);
+
+ const handleAddVendor = async () => {
+ try {
+ setIsAdddialogLoading(true)
+
+ // 필요한 데이터 로드 (벤더, 통화, 지불조건, 인코텀즈)
+ const [vendorsData, currenciesData, paymentTermsData, incotermsData] = await Promise.all([
+ fetchVendors(),
+ fetchCurrencies(),
+ fetchPaymentTerms(),
+ fetchIncoterms()
+ ])
+
+ setVendors(vendorsData.data || [])
+ setCurrencies(currenciesData.data || [])
+ setPaymentTerms(paymentTermsData.data || [])
+ setIncoterms(incotermsData.data || [])
+
+ setVendorDialogOpen(true)
+ } catch (error) {
+ console.error("데이터 로드 오류:", error)
+ toast.error("벤더 정보를 불러오는 중 오류가 발생했습니다")
+ } finally {
+ setIsAdddialogLoading(false)
+ }
+ }
+
+ // 견적 비교 다이얼로그 열기 핸들러 (추가)
+ const handleOpenComparisonDialog = () => {
+ // 제출된 견적이 있는 벤더가 최소 1개 이상 있는지 확인
+ const hasSubmittedQuotations = details.some(detail =>
+ detail.hasQuotation && detail.quotationStatus === "Submitted"
+ );
+
+ if (!hasSubmittedQuotations) {
+ toast.warning("제출된 견적이 없습니다.");
+ return;
+ }
+
+ setComparisonDialogOpen(true);
+ }
+
+ // 읽지 않은 메시지 로드
+ const loadUnreadMessages = async () => {
+ if (!selectedRfq || !selectedRfq.id) return;
+
+ try {
+ setIsUnreadLoading(true);
+
+ // 읽지 않은 메시지 수 가져오기
+ const unreadData = await fetchUnreadMessages(selectedRfq.id);
+ setUnreadMessages(unreadData);
+ } catch (error) {
+ console.error("읽지 않은 메시지 로드 오류:", error);
+ // 조용히 실패 - 사용자에게 알림 표시하지 않음
+ } finally {
+ setIsUnreadLoading(false);
+ }
+ };
+
+ // 칼럼 정의 - unreadMessages 상태 전달
+ const columns = React.useMemo(() =>
+ getRfqDetailColumns({
+ setRowAction,
+ unreadMessages
+ }), [unreadMessages])
+
+ // 필터 필드 정의 (필터 사용 시)
+ const advancedFilterFields = React.useMemo(
+ () => [
+ {
+ id: "vendorName",
+ label: "벤더명",
+ type: "text",
+ },
+ {
+ id: "vendorCode",
+ label: "벤더 코드",
+ type: "text",
+ },
+ {
+ id: "currency",
+ label: "통화",
+ type: "text",
+ },
+ ],
+ []
+ )
+
+ // RFQ ID가 변경될 때 데이터 로드
+ useEffect(() => {
+ async function loadRfqDetails() {
+ if (!selectedRfq || !selectedRfq.id) {
+ setDetails([])
+ return
+ }
+
+ try {
+ setIsLoading(true)
+ const transformRfqDetails = (data: any[]): RfqDetailView[] => {
+ return data.map(item => ({
+ ...item,
+ // Convert vendorId from string|null to number|undefined
+ vendorId: item.vendorId ? Number(item.vendorId) : undefined,
+ // Transform any other fields that need type conversion
+ }));
+ };
+
+ // Then in your useEffect:
+ const result = await fetchRfqDetails(selectedRfq.id);
+ setDetails(transformRfqDetails(result.data));
+
+ // 읽지 않은 메시지 개수 로드
+ await loadUnreadMessages();
+ } catch (error) {
+ console.error("RFQ 디테일 로드 오류:", error)
+ setDetails([])
+ toast.error("RFQ 세부정보를 불러오는 중 오류가 발생했습니다")
+ } finally {
+ setIsLoading(false)
+ }
+ }
+
+ loadRfqDetails()
+ }, [selectedRfq])
+
+ // 주기적으로 읽지 않은 메시지 갱신 (60초마다)
+ useEffect(() => {
+ if (!selectedRfq || !selectedRfq.id) return;
+
+ const intervalId = setInterval(() => {
+ loadUnreadMessages();
+ }, 60000); // 60초마다 갱신
+
+ return () => clearInterval(intervalId);
+ }, [selectedRfq]);
+
+ // rowAction 처리
+ useEffect(() => {
+ if (!rowAction) return
+
+ const handleRowAction = async () => {
+ try {
+ // 통신 액션인 경우 드로어 열기
+ if (rowAction.type === "communicate") {
+ setSelectedVendor(rowAction.row.original);
+ setCommunicationDrawerOpen(true);
+
+ // 해당 벤더의 읽지 않은 메시지를 0으로 설정 (메시지를 읽은 것으로 간주)
+ const vendorId = rowAction.row.original.vendorId;
+ if (vendorId) {
+ setUnreadMessages(prev => ({
+ ...prev,
+ [vendorId]: 0
+ }));
+ }
+
+ // rowAction 초기화
+ setRowAction(null);
+ return;
+ }
+
+ // 다른 액션들은 기존과 동일하게 처리
+ setIsAdddialogLoading(true);
+
+ // 필요한 데이터 로드 (벤더, 통화, 지불조건, 인코텀즈)
+ const [vendorsData, currenciesData, paymentTermsData, incotermsData] = await Promise.all([
+ fetchVendors(),
+ fetchCurrencies(),
+ fetchPaymentTerms(),
+ fetchIncoterms()
+ ]);
+
+ setVendors(vendorsData.data || []);
+ setCurrencies(currenciesData.data || []);
+ setPaymentTerms(paymentTermsData.data || []);
+ setIncoterms(incotermsData.data || []);
+
+ // 이제 데이터가 로드되었으므로 필요한 작업 수행
+ if (rowAction.type === "update") {
+ setSelectedDetail(rowAction.row.original);
+ setUpdateSheetOpen(true);
+ } else if (rowAction.type === "delete") {
+ setSelectedDetail(rowAction.row.original);
+ setDeleteDialogOpen(true);
+ }
+ } catch (error) {
+ console.error("데이터 로드 오류:", error);
+ toast.error("데이터를 불러오는 중 오류가 발생했습니다");
+ } finally {
+ // communicate 타입이 아닌 경우에만 로딩 상태 변경
+ if (rowAction && rowAction.type !== "communicate") {
+ setIsAdddialogLoading(false);
+ }
+ }
+ };
+
+ handleRowAction();
+ }, [rowAction])
+
+ // RFQ가 선택되지 않은 경우
+ if (!selectedRfq) {
+ return (
+ <div className="flex h-full items-center justify-center text-muted-foreground">
+ RFQ를 선택하세요
+ </div>
+ )
+ }
+
+ // 로딩 중인 경우
+ if (isLoading) {
+ return (
+ <div className="p-4 space-y-4">
+ <Skeleton className="h-8 w-1/2" />
+ <Skeleton className="h-24 w-full" />
+ <Skeleton className="h-48 w-full" />
+ </div>
+ )
+ }
+
+ const handleRefreshData = async () => {
+ if (!selectedRfq || !selectedRfq.id) return
+
+ try {
+ setIsRefreshing(true)
+
+ const transformRfqDetails = (data: any[]): RfqDetailView[] => {
+ return data.map(item => ({
+ ...item,
+ // Convert vendorId from string|null to number|undefined
+ vendorId: item.vendorId ? Number(item.vendorId) : undefined,
+ // Transform any other fields that need type conversion
+ }));
+ };
+
+ // Then in your useEffect:
+ const result = await fetchRfqDetails(selectedRfq.id);
+ setDetails(transformRfqDetails(result.data));
+
+ // 읽지 않은 메시지 개수 업데이트
+ await loadUnreadMessages();
+
+ toast.success("데이터가 새로고침되었습니다")
+ } catch (error) {
+ console.error("RFQ 디테일 로드 오류:", error)
+ toast.error("데이터 새로고침 중 오류가 발생했습니다")
+ } finally {
+ setIsRefreshing(false)
+ }
+ }
+
+ // 전체 읽지 않은 메시지 수 계산
+ const totalUnreadMessages = Object.values(unreadMessages).reduce((sum, count) => sum + count, 0);
+
+ // 견적이 있는 벤더 수 계산
+ const vendorsWithQuotations = details.filter(detail => detail.hasQuotation && detail.quotationStatus === "Submitted").length;
+
+ return (
+ <div className="h-full overflow-hidden pt-4">
+
+ {/* 메시지 및 새로고침 영역 */}
+
+
+ {/* 테이블 또는 빈 상태 표시 */}
+ {details.length > 0 ? (
+
+ <ClientDataTable
+ columns={columns}
+ data={details}
+ advancedFilterFields={advancedFilterFields}
+ maxHeight={maxHeight}
+ >
+
+ <div className="flex justify-between items-center">
+ <div className="flex items-center gap-2 mr-2">
+ {totalUnreadMessages > 0 && (
+ <Badge variant="destructive" className="h-6">
+ 읽지 않은 메시지: {totalUnreadMessages}건
+ </Badge>
+ )}
+ {vendorsWithQuotations > 0 && (
+ <Badge variant="outline" className="h-6">
+ 견적 제출: {vendorsWithQuotations}개 벤더
+ </Badge>
+ )}
+ </div>
+ <div className="flex gap-2">
+ {/* 견적 비교 버튼 추가 */}
+ <Button
+ variant="outline"
+ size="sm"
+ onClick={handleOpenComparisonDialog}
+ className="gap-2"
+ disabled={
+ !selectedRfq ||
+ details.length === 0 ||
+ (!!selectedRfq.rfqSealedYn && selectedRfq.dueDate && new Date() < new Date(selectedRfq.dueDate))
+ }
+ >
+ <BarChart2 className="size-4" aria-hidden="true" />
+ <span>견적 비교</span>
+ </Button>
+ <Button
+ variant="outline"
+ size="sm"
+ onClick={handleRefreshData}
+ disabled={isRefreshing}
+ >
+ {isRefreshing ? (
+ <>
+ <Loader2 className="h-4 w-4 mr-2 animate-spin" />
+ 새로고침 중...
+ </>
+ ) : (
+ '새로고침'
+ )}
+ </Button>
+ </div>
+ </div>
+ <Button
+ variant="outline"
+ size="sm"
+ onClick={handleAddVendor}
+ className="gap-2"
+ disabled={!selectedRfq || isAdddialogLoading}
+ >
+ {isAdddialogLoading ? (
+ <>
+ <Loader2 className="size-4 animate-spin" aria-hidden="true" />
+ <span>로딩 중...</span>
+ </>
+ ) : (
+ <>
+ <UserPlus className="size-4" aria-hidden="true" />
+ <span>벤더 추가</span>
+ </>
+ )}
+ </Button>
+ </ClientDataTable>
+
+ ) : (
+ <div className="flex h-48 items-center justify-center text-muted-foreground border rounded-md p-4">
+ <div className="flex flex-col items-center gap-4">
+ <p>해당 RFQ에 대한 협력업체가 정해지지 않았습니다. 아래 버튼을 이용하여 추가하시기 바랍니다.</p>
+ <Button
+ variant="outline"
+ size="sm"
+ onClick={handleAddVendor}
+ className="gap-2"
+ disabled={!selectedRfq || isAdddialogLoading}
+ >
+ {isAdddialogLoading ? (
+ <>
+ <Loader2 className="size-4 animate-spin" aria-hidden="true" />
+ <span>로딩 중...</span>
+ </>
+ ) : (
+ <>
+ <UserPlus className="size-4" aria-hidden="true" />
+ <span>협력업체 추가</span>
+ </>
+ )}
+ </Button>
+ </div>
+ </div>
+ )}
+
+ {/* 벤더 추가 다이얼로그 */}
+ <AddVendorDialog
+ open={vendorDialogOpen}
+ onOpenChange={(open) => {
+ setVendorDialogOpen(open);
+ if (!open) setIsAdddialogLoading(false);
+ }}
+ selectedRfq={selectedRfq}
+ vendors={vendors}
+ currencies={currencies}
+ paymentTerms={paymentTerms}
+ incoterms={incoterms}
+ onSuccess={handleRefreshData}
+ existingVendorIds={existingVendorIds}
+ />
+
+ {/* 벤더 정보 수정 시트 */}
+ <UpdateRfqDetailSheet
+ open={updateSheetOpen}
+ onOpenChange={setUpdateSheetOpen}
+ detail={selectedDetail}
+ vendors={vendors}
+ currencies={currencies}
+ paymentTerms={paymentTerms}
+ incoterms={incoterms}
+ onSuccess={handleRefreshData}
+ />
+
+ {/* 벤더 정보 삭제 다이얼로그 */}
+ <DeleteRfqDetailDialog
+ open={deleteDialogOpen}
+ onOpenChange={setDeleteDialogOpen}
+ detail={selectedDetail}
+ showTrigger={false}
+ onSuccess={handleRefreshData}
+ />
+
+ {/* 벤더 커뮤니케이션 드로어 */}
+ <VendorCommunicationDrawer
+ open={communicationDrawerOpen}
+ onOpenChange={(open) => {
+ setCommunicationDrawerOpen(open);
+ // 드로어가 닫힐 때 읽지 않은 메시지 개수 갱신
+ if (!open) loadUnreadMessages();
+ }}
+ selectedRfq={selectedRfq}
+ selectedVendor={selectedVendor}
+ onSuccess={handleRefreshData}
+ />
+
+ {/* 견적 비교 다이얼로그 추가 */}
+ <VendorQuotationComparisonDialog
+ open={comparisonDialogOpen}
+ onOpenChange={setComparisonDialogOpen}
+ selectedRfq={selectedRfq}
+ />
+ </div>
+ )
+} \ No newline at end of file
diff --git a/lib/procurement-rfqs/table/detail-table/update-vendor-sheet.tsx b/lib/procurement-rfqs/table/detail-table/update-vendor-sheet.tsx
new file mode 100644
index 00000000..45e4a602
--- /dev/null
+++ b/lib/procurement-rfqs/table/detail-table/update-vendor-sheet.tsx
@@ -0,0 +1,449 @@
+"use client"
+
+import * as React from "react"
+import { zodResolver } from "@hookform/resolvers/zod"
+import { Check, ChevronsUpDown, Loader } from "lucide-react"
+import { useForm } from "react-hook-form"
+import { toast } from "sonner"
+import { z } from "zod"
+
+import { cn } from "@/lib/utils"
+import { Button } from "@/components/ui/button"
+import {
+ Command,
+ CommandEmpty,
+ CommandGroup,
+ CommandInput,
+ CommandItem,
+} from "@/components/ui/command"
+import {
+ Form,
+ FormControl,
+ FormField,
+ FormItem,
+ FormLabel,
+ FormMessage,
+} from "@/components/ui/form"
+import { Input } from "@/components/ui/input"
+import {
+ Popover,
+ PopoverContent,
+ PopoverTrigger,
+} from "@/components/ui/popover"
+import {
+ Select,
+ SelectContent,
+ SelectItem,
+ SelectTrigger,
+ SelectValue,
+} from "@/components/ui/select"
+import {
+ Sheet,
+ SheetClose,
+ SheetContent,
+ SheetDescription,
+ SheetFooter,
+ SheetHeader,
+ SheetTitle,
+} from "@/components/ui/sheet"
+import { Checkbox } from "@/components/ui/checkbox"
+import { ScrollArea } from "@/components/ui/scroll-area"
+
+import { RfqDetailView } from "./rfq-detail-column"
+import { updateRfqDetail } from "@/lib/procurement-rfqs/services"
+
+// 폼 유효성 검증 스키마
+const updateRfqDetailSchema = z.object({
+ vendorId: z.string().min(1, "벤더를 선택해주세요"),
+ currency: z.string().min(1, "통화를 선택해주세요"),
+ paymentTermsCode: z.string().min(1, "지불 조건을 선택해주세요"),
+ incotermsCode: z.string().min(1, "인코텀즈를 선택해주세요"),
+ incotermsDetail: z.string().optional(),
+ deliveryDate: z.string().optional(),
+ taxCode: z.string().optional(),
+ placeOfShipping: z.string().optional(),
+ placeOfDestination: z.string().optional(),
+ materialPriceRelatedYn: z.boolean().default(false),
+})
+
+type UpdateRfqDetailFormValues = z.infer<typeof updateRfqDetailSchema>
+
+// 데이터 타입 정의
+interface Vendor {
+ id: number;
+ vendorName: string;
+ vendorCode: string;
+}
+
+interface Currency {
+ code: string;
+ name: string;
+}
+
+interface PaymentTerm {
+ code: string;
+ description: string;
+}
+
+interface Incoterm {
+ code: string;
+ description: string;
+}
+
+interface UpdateRfqDetailSheetProps
+ extends React.ComponentPropsWithRef<typeof Sheet> {
+ detail: RfqDetailView | null;
+ vendors: Vendor[];
+ currencies: Currency[];
+ paymentTerms: PaymentTerm[];
+ incoterms: Incoterm[];
+ onSuccess?: () => void;
+}
+
+export function UpdateRfqDetailSheet({
+ detail,
+ vendors,
+ currencies,
+ paymentTerms,
+ incoterms,
+ onSuccess,
+ ...props
+}: UpdateRfqDetailSheetProps) {
+ const [isUpdatePending, startUpdateTransition] = React.useTransition()
+ const [vendorOpen, setVendorOpen] = React.useState(false)
+
+ const form = useForm<UpdateRfqDetailFormValues>({
+ resolver: zodResolver(updateRfqDetailSchema),
+ defaultValues: {
+ vendorId: detail?.vendorName ? String(vendors.find(v => v.vendorName === detail.vendorName)?.id || "") : "",
+ currency: detail?.currency || "",
+ paymentTermsCode: detail?.paymentTermsCode || "",
+ incotermsCode: detail?.incotermsCode || "",
+ incotermsDetail: detail?.incotermsDetail || "",
+ deliveryDate: detail?.deliveryDate ? new Date(detail.deliveryDate).toISOString().split('T')[0] : "",
+ taxCode: detail?.taxCode || "",
+ placeOfShipping: detail?.placeOfShipping || "",
+ placeOfDestination: detail?.placeOfDestination || "",
+ materialPriceRelatedYn: detail?.materialPriceRelatedYn || false,
+ },
+ })
+
+ // detail이 변경될 때 form 값 업데이트
+ React.useEffect(() => {
+ if (detail) {
+ const vendorId = vendors.find(v => v.vendorName === detail.vendorName)?.id
+
+ form.reset({
+ vendorId: vendorId ? String(vendorId) : "",
+ currency: detail.currency || "",
+ paymentTermsCode: detail.paymentTermsCode || "",
+ incotermsCode: detail.incotermsCode || "",
+ incotermsDetail: detail.incotermsDetail || "",
+ deliveryDate: detail.deliveryDate ? new Date(detail.deliveryDate).toISOString().split('T')[0] : "",
+ taxCode: detail.taxCode || "",
+ placeOfShipping: detail.placeOfShipping || "",
+ placeOfDestination: detail.placeOfDestination || "",
+ materialPriceRelatedYn: detail.materialPriceRelatedYn || false,
+ })
+ }
+ }, [detail, form, vendors])
+
+ function onSubmit(values: UpdateRfqDetailFormValues) {
+ if (!detail) return
+
+ startUpdateTransition(async () => {
+ try {
+ const result = await updateRfqDetail(detail.detailId, values)
+
+ if (!result.success) {
+ toast.error(result.message || "수정 중 오류가 발생했습니다")
+ return
+ }
+
+ props.onOpenChange?.(false)
+ toast.success("RFQ 벤더 정보가 수정되었습니다")
+ onSuccess?.()
+ } catch (error) {
+ console.error("RFQ 벤더 수정 오류:", error)
+ toast.error("수정 중 오류가 발생했습니다")
+ }
+ })
+ }
+
+ return (
+ <Sheet {...props}>
+ <SheetContent className="flex w-full flex-col gap-6 sm:max-w-xl">
+ <SheetHeader className="text-left">
+ <SheetTitle>RFQ 벤더 정보 수정</SheetTitle>
+ <SheetDescription>
+ 벤더 정보를 수정하고 저장하세요
+ </SheetDescription>
+ </SheetHeader>
+ <ScrollArea className="flex-1 pr-4">
+ <Form {...form}>
+ <form
+ id="update-rfq-detail-form"
+ onSubmit={form.handleSubmit(onSubmit)}
+ className="flex flex-col gap-4"
+ >
+ {/* 검색 가능한 벤더 선택 필드 */}
+ <FormField
+ control={form.control}
+ name="vendorId"
+ render={({ field }) => (
+ <FormItem className="flex flex-col">
+ <FormLabel>벤더 <span className="text-red-500">*</span></FormLabel>
+ <Popover open={vendorOpen} onOpenChange={setVendorOpen}>
+ <PopoverTrigger asChild>
+ <FormControl>
+ <Button
+ variant="outline"
+ role="combobox"
+ aria-expanded={vendorOpen}
+ className={cn(
+ "w-full justify-between",
+ !field.value && "text-muted-foreground"
+ )}
+ >
+ {field.value
+ ? vendors.find((vendor) => String(vendor.id) === field.value)
+ ? `${vendors.find((vendor) => String(vendor.id) === field.value)?.vendorName} (${vendors.find((vendor) => String(vendor.id) === field.value)?.vendorCode})`
+ : "벤더를 선택하세요"
+ : "벤더를 선택하세요"}
+ <ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
+ </Button>
+ </FormControl>
+ </PopoverTrigger>
+ <PopoverContent className="w-[400px] p-0">
+ <Command>
+ <CommandInput placeholder="벤더 검색..." />
+ <CommandEmpty>검색 결과가 없습니다</CommandEmpty>
+ <ScrollArea className="h-60">
+ <CommandGroup>
+ {vendors.map((vendor) => (
+ <CommandItem
+ key={vendor.id}
+ value={`${vendor.vendorName} ${vendor.vendorCode}`}
+ onSelect={() => {
+ form.setValue("vendorId", String(vendor.id), {
+ shouldValidate: true,
+ })
+ setVendorOpen(false)
+ }}
+ >
+ <Check
+ className={cn(
+ "mr-2 h-4 w-4",
+ String(vendor.id) === field.value
+ ? "opacity-100"
+ : "opacity-0"
+ )}
+ />
+ {vendor.vendorName} ({vendor.vendorCode})
+ </CommandItem>
+ ))}
+ </CommandGroup>
+ </ScrollArea>
+ </Command>
+ </PopoverContent>
+ </Popover>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+
+ <FormField
+ control={form.control}
+ name="currency"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>통화 <span className="text-red-500">*</span></FormLabel>
+ <Select onValueChange={field.onChange} value={field.value}>
+ <FormControl>
+ <SelectTrigger>
+ <SelectValue placeholder="통화를 선택하세요" />
+ </SelectTrigger>
+ </FormControl>
+ <SelectContent>
+ {currencies.map((currency) => (
+ <SelectItem key={currency.code} value={currency.code}>
+ {currency.name} ({currency.code})
+ </SelectItem>
+ ))}
+ </SelectContent>
+ </Select>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+
+ <div className="grid grid-cols-2 gap-4">
+ <FormField
+ control={form.control}
+ name="paymentTermsCode"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>지불 조건 <span className="text-red-500">*</span></FormLabel>
+ <Select onValueChange={field.onChange} value={field.value}>
+ <FormControl>
+ <SelectTrigger>
+ <SelectValue placeholder="지불 조건 선택" />
+ </SelectTrigger>
+ </FormControl>
+ <SelectContent>
+ {paymentTerms.map((term) => (
+ <SelectItem key={term.code} value={term.code}>
+ {term.description}
+ </SelectItem>
+ ))}
+ </SelectContent>
+ </Select>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+
+ <FormField
+ control={form.control}
+ name="incotermsCode"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>인코텀즈 <span className="text-red-500">*</span></FormLabel>
+ <Select onValueChange={field.onChange} value={field.value}>
+ <FormControl>
+ <SelectTrigger>
+ <SelectValue placeholder="인코텀즈 선택" />
+ </SelectTrigger>
+ </FormControl>
+ <SelectContent>
+ {incoterms.map((incoterm) => (
+ <SelectItem key={incoterm.code} value={incoterm.code}>
+ {incoterm.description}
+ </SelectItem>
+ ))}
+ </SelectContent>
+ </Select>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+ </div>
+
+ <FormField
+ control={form.control}
+ name="incotermsDetail"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>인코텀즈 세부사항</FormLabel>
+ <FormControl>
+ <Input {...field} placeholder="인코텀즈 세부사항" />
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+
+ <div className="grid grid-cols-2 gap-4">
+ <FormField
+ control={form.control}
+ name="deliveryDate"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>납품 예정일</FormLabel>
+ <FormControl>
+ <Input {...field} type="date" />
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+
+ <FormField
+ control={form.control}
+ name="taxCode"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>세금 코드</FormLabel>
+ <FormControl>
+ <Input {...field} placeholder="세금 코드" />
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+ </div>
+
+ <div className="grid grid-cols-2 gap-4">
+ <FormField
+ control={form.control}
+ name="placeOfShipping"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>선적지</FormLabel>
+ <FormControl>
+ <Input {...field} placeholder="선적지" />
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+
+ <FormField
+ control={form.control}
+ name="placeOfDestination"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>도착지</FormLabel>
+ <FormControl>
+ <Input {...field} placeholder="도착지" />
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+ </div>
+
+ <FormField
+ control={form.control}
+ name="materialPriceRelatedYn"
+ render={({ field }) => (
+ <FormItem className="flex flex-row items-start space-x-3 space-y-0 rounded-md border p-4">
+ <FormControl>
+ <Checkbox
+ checked={field.value}
+ onCheckedChange={field.onChange}
+ />
+ </FormControl>
+ <div className="space-y-1 leading-none">
+ <FormLabel>자재 가격 관련 여부</FormLabel>
+ </div>
+ </FormItem>
+ )}
+ />
+ </form>
+ </Form>
+ </ScrollArea>
+ <SheetFooter className="gap-2 pt-2 sm:space-x-0">
+ <SheetClose asChild>
+ <Button type="button" variant="outline">
+ 취소
+ </Button>
+ </SheetClose>
+ <Button
+ type="submit"
+ form="update-rfq-detail-form"
+ disabled={isUpdatePending}
+ >
+ {isUpdatePending && (
+ <Loader
+ className="mr-2 size-4 animate-spin"
+ aria-hidden="true"
+ />
+ )}
+ 저장
+ </Button>
+ </SheetFooter>
+ </SheetContent>
+ </Sheet>
+ )
+} \ No newline at end of file
diff --git a/lib/procurement-rfqs/table/detail-table/vendor-communication-drawer.tsx b/lib/procurement-rfqs/table/detail-table/vendor-communication-drawer.tsx
new file mode 100644
index 00000000..34efdfc2
--- /dev/null
+++ b/lib/procurement-rfqs/table/detail-table/vendor-communication-drawer.tsx
@@ -0,0 +1,518 @@
+"use client"
+
+import * as React from "react"
+import { useState, useEffect, useRef } from "react"
+import { ProcurementRfqsView } from "@/db/schema"
+import { RfqDetailView } from "./rfq-detail-column"
+import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
+import { Button } from "@/components/ui/button"
+import { Textarea } from "@/components/ui/textarea"
+import { Avatar, AvatarFallback } from "@/components/ui/avatar"
+import {
+ Drawer,
+ DrawerClose,
+ DrawerContent,
+ DrawerDescription,
+ DrawerFooter,
+ DrawerHeader,
+ DrawerTitle,
+} from "@/components/ui/drawer"
+import { ScrollArea } from "@/components/ui/scroll-area"
+import { Badge } from "@/components/ui/badge"
+import { toast } from "sonner"
+import {
+ Send,
+ Paperclip,
+ DownloadCloud,
+ File,
+ FileText,
+ Image as ImageIcon,
+ AlertCircle,
+ X
+} from "lucide-react"
+import {
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogHeader,
+ DialogTitle,
+} from "@/components/ui/dialog"
+import { formatDateTime } from "@/lib/utils"
+import { formatFileSize } from "@/lib/utils" // formatFileSize 유틸리티 임포트
+import { fetchVendorComments, markMessagesAsRead } from "@/lib/procurement-rfqs/services"
+
+// 타입 정의
+interface Comment {
+ id: number;
+ rfqId: number;
+ vendorId: number | null // null 허용으로 변경
+ userId?: number | null // null 허용으로 변경
+ content: string;
+ isVendorComment: boolean | null; // null 허용으로 변경
+ createdAt: Date;
+ updatedAt: Date;
+ userName?: string | null // null 허용으로 변경
+ vendorName?: string | null // null 허용으로 변경
+ attachments: Attachment[];
+ isRead: boolean | null // null 허용으로 변경
+}
+
+interface Attachment {
+ id: number;
+ fileName: string;
+ fileSize: number;
+ fileType: string;
+ filePath: string;
+ uploadedAt: Date;
+}
+
+// 프롭스 정의
+interface VendorCommunicationDrawerProps {
+ open: boolean;
+ onOpenChange: (open: boolean) => void;
+ selectedRfq: ProcurementRfqsView | null;
+ selectedVendor: RfqDetailView | null;
+ onSuccess?: () => void;
+}
+
+async function sendComment(params: {
+ rfqId: number;
+ vendorId: number;
+ content: string;
+ attachments?: File[];
+}): Promise<Comment> {
+ try {
+ // 폼 데이터 생성 (파일 첨부를 위해)
+ const formData = new FormData();
+ formData.append('rfqId', params.rfqId.toString());
+ formData.append('vendorId', params.vendorId.toString());
+ formData.append('content', params.content);
+ formData.append('isVendorComment', 'false');
+
+ // 첨부파일 추가
+ if (params.attachments && params.attachments.length > 0) {
+ params.attachments.forEach((file) => {
+ formData.append(`attachments`, file);
+ });
+ }
+
+ // API 엔드포인트 구성
+ const url = `/api/procurement-rfqs/${params.rfqId}/vendors/${params.vendorId}/comments`;
+
+ // API 호출
+ const response = await fetch(url, {
+ method: 'POST',
+ body: formData, // multipart/form-data 형식 사용
+ });
+
+ if (!response.ok) {
+ const errorText = await response.text();
+ throw new Error(`API 요청 실패: ${response.status} ${errorText}`);
+ }
+
+ // 응답 데이터 파싱
+ const result = await response.json();
+
+ if (!result.success || !result.data) {
+ throw new Error(result.message || '코멘트 전송 중 오류가 발생했습니다');
+ }
+
+ return result.data.comment;
+ } catch (error) {
+ console.error('코멘트 전송 오류:', error);
+ throw error;
+ }
+}
+
+export function VendorCommunicationDrawer({
+ open,
+ onOpenChange,
+ selectedRfq,
+ selectedVendor,
+ onSuccess
+}: VendorCommunicationDrawerProps) {
+ // 상태 관리
+ const [comments, setComments] = useState<Comment[]>([]);
+ const [newComment, setNewComment] = useState("");
+ const [attachments, setAttachments] = useState<File[]>([]);
+ const [isLoading, setIsLoading] = useState(false);
+ const [isSubmitting, setIsSubmitting] = useState(false);
+ const fileInputRef = useRef<HTMLInputElement>(null);
+ const messagesEndRef = useRef<HTMLDivElement>(null);
+
+ // 첨부파일 관련 상태
+ const [previewDialogOpen, setPreviewDialogOpen] = useState(false);
+ const [selectedAttachment, setSelectedAttachment] = useState<Attachment | null>(null);
+
+ // 드로어가 열릴 때 데이터 로드
+ useEffect(() => {
+ if (open && selectedRfq && selectedVendor) {
+ loadComments();
+ }
+ }, [open, selectedRfq, selectedVendor]);
+
+ // 스크롤 최하단으로 이동
+ useEffect(() => {
+ if (messagesEndRef.current) {
+ messagesEndRef.current.scrollIntoView({ behavior: "smooth" });
+ }
+ }, [comments]);
+
+ // 코멘트 로드 함수
+ const loadComments = async () => {
+ if (!selectedRfq || !selectedVendor) return;
+
+ try {
+ setIsLoading(true);
+
+ // Server Action을 사용하여 코멘트 데이터 가져오기
+ const commentsData = await fetchVendorComments(selectedRfq.id, selectedVendor.vendorId);
+ setComments(commentsData);
+
+ // Server Action을 사용하여 읽지 않은 메시지를 읽음 상태로 변경
+ await markMessagesAsRead(selectedRfq.id, selectedVendor.vendorId);
+ } catch (error) {
+ console.error("코멘트 로드 오류:", error);
+ toast.error("메시지를 불러오는 중 오류가 발생했습니다");
+ } finally {
+ setIsLoading(false);
+ }
+ };
+
+ // 파일 선택 핸들러
+ const handleFileSelect = () => {
+ fileInputRef.current?.click();
+ };
+
+ // 파일 변경 핸들러
+ const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
+ if (e.target.files && e.target.files.length > 0) {
+ const newFiles = Array.from(e.target.files);
+ setAttachments(prev => [...prev, ...newFiles]);
+ }
+ };
+
+ // 파일 제거 핸들러
+ const handleRemoveFile = (index: number) => {
+ setAttachments(prev => prev.filter((_, i) => i !== index));
+ };
+
+ console.log(newComment)
+
+ // 코멘트 전송 핸들러
+ const handleSubmitComment = async () => {
+ console.log("버튼 클릭1", selectedRfq,selectedVendor, selectedVendor?.vendorId )
+ console.log(!newComment.trim() && attachments.length === 0)
+
+ if (!newComment.trim() && attachments.length === 0) return;
+ if (!selectedRfq || !selectedVendor || !selectedVendor.vendorId) return;
+
+ console.log("버튼 클릭")
+
+ try {
+ setIsSubmitting(true);
+
+ // API를 사용하여 새 코멘트 전송 (파일 업로드 때문에 FormData 사용)
+ const newCommentObj = await sendComment({
+ rfqId: selectedRfq.id,
+ vendorId: selectedVendor.vendorId,
+ content: newComment,
+ attachments: attachments
+ });
+
+ // 상태 업데이트
+ setComments(prev => [...prev, newCommentObj]);
+ setNewComment("");
+ setAttachments([]);
+
+ toast.success("메시지가 전송되었습니다");
+
+ // 데이터 새로고침
+ if (onSuccess) {
+ onSuccess();
+ }
+ } catch (error) {
+ console.error("코멘트 전송 오류:", error);
+ toast.error("메시지 전송 중 오류가 발생했습니다");
+ } finally {
+ setIsSubmitting(false);
+ }
+ };
+
+ // 첨부파일 미리보기
+ const handleAttachmentPreview = (attachment: Attachment) => {
+ setSelectedAttachment(attachment);
+ setPreviewDialogOpen(true);
+ };
+
+ // 첨부파일 다운로드
+ const handleAttachmentDownload = (attachment: Attachment) => {
+ // TODO: 실제 다운로드 구현
+ window.open(attachment.filePath, '_blank');
+ };
+
+ // 파일 아이콘 선택
+ const getFileIcon = (fileType: string) => {
+ if (fileType.startsWith("image/")) return <ImageIcon className="h-5 w-5 text-blue-500" />;
+ if (fileType.includes("pdf")) return <FileText className="h-5 w-5 text-red-500" />;
+ if (fileType.includes("spreadsheet") || fileType.includes("excel"))
+ return <FileText className="h-5 w-5 text-green-500" />;
+ if (fileType.includes("document") || fileType.includes("word"))
+ return <FileText className="h-5 w-5 text-blue-500" />;
+ return <File className="h-5 w-5 text-gray-500" />;
+ };
+
+ // 첨부파일 미리보기 다이얼로그
+ const renderAttachmentPreviewDialog = () => {
+ if (!selectedAttachment) return null;
+
+ const isImage = selectedAttachment.fileType.startsWith("image/");
+ const isPdf = selectedAttachment.fileType.includes("pdf");
+
+ return (
+ <Dialog open={previewDialogOpen} onOpenChange={setPreviewDialogOpen}>
+ <DialogContent className="max-w-3xl">
+ <DialogHeader>
+ <DialogTitle className="flex items-center gap-2">
+ {getFileIcon(selectedAttachment.fileType)}
+ {selectedAttachment.fileName}
+ </DialogTitle>
+ <DialogDescription>
+ {formatFileSize(selectedAttachment.fileSize)} • {formatDateTime(selectedAttachment.uploadedAt)}
+ </DialogDescription>
+ </DialogHeader>
+
+ <div className="min-h-[300px] flex items-center justify-center p-4">
+ {isImage ? (
+ <img
+ src={selectedAttachment.filePath}
+ alt={selectedAttachment.fileName}
+ className="max-h-[500px] max-w-full object-contain"
+ />
+ ) : isPdf ? (
+ <iframe
+ src={`${selectedAttachment.filePath}#toolbar=0`}
+ className="w-full h-[500px]"
+ title={selectedAttachment.fileName}
+ />
+ ) : (
+ <div className="flex flex-col items-center gap-4 p-8">
+ {getFileIcon(selectedAttachment.fileType)}
+ <p className="text-muted-foreground text-sm">미리보기를 지원하지 않는 파일 형식입니다.</p>
+ <Button
+ variant="outline"
+ onClick={() => handleAttachmentDownload(selectedAttachment)}
+ >
+ <DownloadCloud className="h-4 w-4 mr-2" />
+ 다운로드
+ </Button>
+ </div>
+ )}
+ </div>
+ </DialogContent>
+ </Dialog>
+ );
+ };
+
+ if (!selectedRfq || !selectedVendor) {
+ return null;
+ }
+
+ return (
+ <Drawer open={open} onOpenChange={onOpenChange}>
+ <DrawerContent className="max-h-[85vh]">
+ <DrawerHeader className="border-b">
+ <DrawerTitle className="flex items-center gap-2">
+ <Avatar className="h-8 w-8">
+ <AvatarFallback className="bg-primary/10">
+ {selectedVendor.vendorName?.[0] || 'V'}
+ </AvatarFallback>
+ </Avatar>
+ <div>
+ <span>{selectedVendor.vendorName}</span>
+ <Badge variant="outline" className="ml-2">{selectedVendor.vendorCode}</Badge>
+ </div>
+ </DrawerTitle>
+ <DrawerDescription>
+ RFQ: {selectedRfq.rfqCode} • 프로젝트: {selectedRfq.projectName}
+ </DrawerDescription>
+ </DrawerHeader>
+
+ <div className="p-0 flex flex-col h-[60vh]">
+ {/* 메시지 목록 */}
+ <ScrollArea className="flex-1 p-4">
+ {isLoading ? (
+ <div className="flex h-full items-center justify-center">
+ <p className="text-muted-foreground">메시지 로딩 중...</p>
+ </div>
+ ) : comments.length === 0 ? (
+ <div className="flex h-full items-center justify-center">
+ <div className="flex flex-col items-center gap-2">
+ <AlertCircle className="h-6 w-6 text-muted-foreground" />
+ <p className="text-muted-foreground">아직 메시지가 없습니다</p>
+ </div>
+ </div>
+ ) : (
+ <div className="space-y-4">
+ {comments.map(comment => (
+ <div
+ key={comment.id}
+ className={`flex gap-3 ${comment.isVendorComment ? 'justify-start' : 'justify-end'}`}
+ >
+ {comment.isVendorComment && (
+ <Avatar className="h-8 w-8 mt-1">
+ <AvatarFallback className="bg-primary/10">
+ {comment.vendorName?.[0] || 'V'}
+ </AvatarFallback>
+ </Avatar>
+ )}
+
+ <div className={`rounded-lg p-3 max-w-[80%] ${
+ comment.isVendorComment
+ ? 'bg-muted'
+ : 'bg-primary text-primary-foreground'
+ }`}>
+ <div className="text-sm font-medium mb-1">
+ {comment.isVendorComment ? comment.vendorName : comment.userName}
+ </div>
+
+ {comment.content && (
+ <div className="text-sm whitespace-pre-wrap break-words">
+ {comment.content}
+ </div>
+ )}
+
+ {/* 첨부파일 표시 */}
+ {comment.attachments.length > 0 && (
+ <div className={`mt-2 pt-2 ${
+ comment.isVendorComment
+ ? 'border-t border-t-border/30'
+ : 'border-t border-t-primary-foreground/20'
+ }`}>
+ {comment.attachments.map(attachment => (
+ <div
+ key={attachment.id}
+ className="flex items-center text-xs gap-2 mb-1 p-1 rounded hover:bg-black/5 cursor-pointer"
+ onClick={() => handleAttachmentPreview(attachment)}
+ >
+ {getFileIcon(attachment.fileType)}
+ <span className="flex-1 truncate">{attachment.fileName}</span>
+ <span className="text-xs opacity-70">
+ {formatFileSize(attachment.fileSize)}
+ </span>
+ <Button
+ variant="ghost"
+ size="icon"
+ className="h-6 w-6 rounded-full"
+ onClick={(e) => {
+ e.stopPropagation();
+ handleAttachmentDownload(attachment);
+ }}
+ >
+ <DownloadCloud className="h-3 w-3" />
+ </Button>
+ </div>
+ ))}
+ </div>
+ )}
+
+ <div className="text-xs mt-1 opacity-70 flex items-center gap-1 justify-end">
+ {formatDateTime(comment.createdAt)}
+ </div>
+ </div>
+
+ {!comment.isVendorComment && (
+ <Avatar className="h-8 w-8 mt-1">
+ <AvatarFallback className="bg-primary/20">
+ {comment.userName?.[0] || 'U'}
+ </AvatarFallback>
+ </Avatar>
+ )}
+ </div>
+ ))}
+ <div ref={messagesEndRef} />
+ </div>
+ )}
+ </ScrollArea>
+
+ {/* 선택된 첨부파일 표시 */}
+ {attachments.length > 0 && (
+ <div className="p-2 bg-muted mx-4 rounded-md mb-2">
+ <div className="text-xs font-medium mb-1">첨부파일</div>
+ <div className="flex flex-wrap gap-2">
+ {attachments.map((file, index) => (
+ <div key={index} className="flex items-center bg-background rounded-md p-1 pr-2 text-xs">
+ {file.type.startsWith("image/") ? (
+ <ImageIcon className="h-4 w-4 mr-1 text-blue-500" />
+ ) : (
+ <File className="h-4 w-4 mr-1 text-gray-500" />
+ )}
+ <span className="truncate max-w-[100px]">{file.name}</span>
+ <Button
+ variant="ghost"
+ size="icon"
+ className="h-4 w-4 ml-1 p-0"
+ onClick={() => handleRemoveFile(index)}
+ >
+ <X className="h-3 w-3" />
+ </Button>
+ </div>
+ ))}
+ </div>
+ </div>
+ )}
+
+ {/* 메시지 입력 영역 */}
+ <div className="p-4 border-t">
+ <div className="flex gap-2 items-end">
+ <div className="flex-1">
+ <Textarea
+ placeholder="메시지를 입력하세요..."
+ className="min-h-[80px] resize-none"
+ value={newComment}
+ onChange={(e) => setNewComment(e.target.value)}
+ />
+ </div>
+ <div className="flex flex-col gap-2">
+ <input
+ type="file"
+ ref={fileInputRef}
+ className="hidden"
+ multiple
+ onChange={handleFileChange}
+ />
+ <Button
+ variant="outline"
+ size="icon"
+ onClick={handleFileSelect}
+ title="파일 첨부"
+ >
+ <Paperclip className="h-4 w-4" />
+ </Button>
+ <Button
+ onClick={handleSubmitComment}
+ disabled={(!newComment.trim() && attachments.length === 0) || isSubmitting}
+ >
+ <Send className="h-4 w-4" />
+ </Button>
+ </div>
+ </div>
+ </div>
+ </div>
+
+ <DrawerFooter className="border-t">
+ <div className="flex justify-between">
+ <Button variant="outline" onClick={() => loadComments()}>
+ 새로고침
+ </Button>
+ <DrawerClose asChild>
+ <Button variant="outline">닫기</Button>
+ </DrawerClose>
+ </div>
+ </DrawerFooter>
+ </DrawerContent>
+
+ {renderAttachmentPreviewDialog()}
+ </Drawer>
+ );
+} \ No newline at end of file
diff --git a/lib/procurement-rfqs/table/detail-table/vendor-quotation-comparison-dialog.tsx b/lib/procurement-rfqs/table/detail-table/vendor-quotation-comparison-dialog.tsx
new file mode 100644
index 00000000..72cf187c
--- /dev/null
+++ b/lib/procurement-rfqs/table/detail-table/vendor-quotation-comparison-dialog.tsx
@@ -0,0 +1,665 @@
+"use client"
+
+import * as React from "react"
+import { useEffect, useState } from "react"
+import {
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogFooter,
+ DialogHeader,
+ DialogTitle,
+} from "@/components/ui/dialog"
+import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
+import { Badge } from "@/components/ui/badge"
+import { Button } from "@/components/ui/button"
+import { Skeleton } from "@/components/ui/skeleton"
+import {
+ Table,
+ TableBody,
+ TableCell,
+ TableHead,
+ TableHeader,
+ TableRow,
+} from "@/components/ui/table"
+import { toast } from "sonner"
+
+// Lucide 아이콘
+import { Plus, Minus } from "lucide-react"
+
+import { ProcurementRfqsView } from "@/db/schema"
+import { fetchVendorQuotations, fetchQuotationItems } from "@/lib/procurement-rfqs/services"
+import { formatCurrency, formatDate } from "@/lib/utils"
+
+// 견적 정보 타입
+interface VendorQuotation {
+ id: number
+ rfqId: number
+ vendorId: number
+ vendorName?: string | null
+ quotationCode: string
+ quotationVersion: number
+ totalItemsCount: number
+ subTotal: string
+ taxTotal: string
+ discountTotal: string
+ totalPrice: string
+ currency: string
+ validUntil: string | Date // 수정: string | Date 허용
+ estimatedDeliveryDate: string | Date // 수정: string | Date 허용
+ paymentTermsCode: string
+ paymentTermsDescription?: string | null
+ incotermsCode: string
+ incotermsDescription?: string | null
+ incotermsDetail: string
+ status: string
+ remark: string
+ rejectionReason: string
+ submittedAt: string | Date // 수정: string | Date 허용
+ acceptedAt: string | Date // 수정: string | Date 허용
+ createdAt: string | Date // 수정: string | Date 허용
+ updatedAt: string | Date // 수정: string | Date 허용
+}
+
+// 견적 아이템 타입
+interface QuotationItem {
+ id: number
+ quotationId: number
+ prItemId: number
+ materialCode: string | null // Changed from string to string | null
+ materialDescription: string | null // Changed from string to string | null
+ quantity: string
+ uom: string | null // Changed assuming this might be null
+ unitPrice: string
+ totalPrice: string
+ currency: string | null // Changed from string to string | null
+ vendorMaterialCode: string | null // Changed from string to string | null
+ vendorMaterialDescription: string | null // Changed from string to string | null
+ deliveryDate: Date | null // Changed from string to string | null
+ leadTimeInDays: number | null // Changed from number to number | null
+ taxRate: string | null // Changed from string to string | null
+ taxAmount: string | null // Changed from string to string | null
+ discountRate: string | null // Changed from string to string | null
+ discountAmount: string | null // Changed from string to string | null
+ remark: string | null // Changed from string to string | null
+ isAlternative: boolean | null // Changed from boolean to boolean | null
+ isRecommended: boolean | null // Changed from boolean to boolean | null
+}
+
+interface VendorQuotationComparisonDialogProps {
+ open: boolean
+ onOpenChange: (open: boolean) => void
+ selectedRfq: ProcurementRfqsView | null
+}
+
+export function VendorQuotationComparisonDialog({
+ open,
+ onOpenChange,
+ selectedRfq,
+}: VendorQuotationComparisonDialogProps) {
+ const [isLoading, setIsLoading] = useState(false)
+ const [quotations, setQuotations] = useState<VendorQuotation[]>([])
+ const [quotationItems, setQuotationItems] = useState<Record<number, QuotationItem[]>>({})
+ const [activeTab, setActiveTab] = useState("summary")
+
+ // 벤더별 접힘 상태 (true=접힘, false=펼침), 기본값: 접힘
+ const [collapsedVendors, setCollapsedVendors] = useState<Record<number, boolean>>({})
+
+ useEffect(() => {
+ async function loadQuotationData() {
+ if (!open || !selectedRfq?.id) return
+
+ try {
+ setIsLoading(true)
+ // 1) 견적 목록
+ const quotationsResult = await fetchVendorQuotations(selectedRfq.id)
+ const rawQuotationsData = quotationsResult.data || []
+
+ const quotationsData = rawQuotationsData.map((rawData): VendorQuotation => ({
+ id: rawData.id,
+ rfqId: rawData.rfqId,
+ vendorId: rawData.vendorId,
+ vendorName: rawData.vendorName || null,
+ quotationCode: rawData.quotationCode || '',
+ quotationVersion: rawData.quotationVersion || 0,
+ totalItemsCount: rawData.totalItemsCount || 0,
+ subTotal: rawData.subTotal || '0',
+ taxTotal: rawData.taxTotal || '0',
+ discountTotal: rawData.discountTotal || '0',
+ totalPrice: rawData.totalPrice || '0',
+ currency: rawData.currency || 'KRW',
+ validUntil: rawData.validUntil || '',
+ estimatedDeliveryDate: rawData.estimatedDeliveryDate || '',
+ paymentTermsCode: rawData.paymentTermsCode || '',
+ paymentTermsDescription: rawData.paymentTermsDescription || null,
+ incotermsCode: rawData.incotermsCode || '',
+ incotermsDescription: rawData.incotermsDescription || null,
+ incotermsDetail: rawData.incotermsDetail || '',
+ status: rawData.status || '',
+ remark: rawData.remark || '',
+ rejectionReason: rawData.rejectionReason || '',
+ submittedAt: rawData.submittedAt || '',
+ acceptedAt: rawData.acceptedAt || '',
+ createdAt: rawData.createdAt || '',
+ updatedAt: rawData.updatedAt || '',
+ }));
+
+ setQuotations(quotationsData);
+
+ // 벤더별로 접힘 상태 기본값(true) 설정
+ const collapsedInit: Record<number, boolean> = {}
+ quotationsData.forEach((q) => {
+ collapsedInit[q.id] = true
+ })
+ setCollapsedVendors(collapsedInit)
+
+ // 2) 견적 아이템
+ const qIds = quotationsData.map((q) => q.id)
+ if (qIds.length > 0) {
+ const itemsResult = await fetchQuotationItems(qIds)
+ const itemsData = itemsResult.data || []
+
+ const itemsByQuotation: Record<number, QuotationItem[]> = {}
+ itemsData.forEach((item) => {
+ if (!itemsByQuotation[item.quotationId]) {
+ itemsByQuotation[item.quotationId] = []
+ }
+ itemsByQuotation[item.quotationId].push(item)
+ })
+ setQuotationItems(itemsByQuotation)
+ }
+ } catch (error) {
+ console.error("견적 데이터 로드 오류:", error)
+ toast.error("견적 데이터를 불러오는 데 실패했습니다")
+ } finally {
+ setIsLoading(false)
+ }
+ }
+
+ loadQuotationData()
+ }, [open, selectedRfq])
+
+ // 견적 상태 -> 뱃지 색
+ const getStatusBadgeVariant = (status: string) => {
+ switch (status) {
+ case "Submitted":
+ return "default"
+ case "Accepted":
+ return "default"
+ case "Rejected":
+ return "destructive"
+ case "Revised":
+ return "destructive"
+ default:
+ return "secondary"
+ }
+ }
+
+ // 모든 prItemId 모음
+ const allItemIds = React.useMemo(() => {
+ const itemSet = new Set<number>()
+ Object.values(quotationItems).forEach((items) => {
+ items.forEach((it) => itemSet.add(it.prItemId))
+ })
+ return Array.from(itemSet)
+ }, [quotationItems])
+
+ // 아이템 찾는 함수
+ const findItemByQuotationId = (prItemId: number, qid: number) => {
+ const items = quotationItems[qid] || []
+ return items.find((i) => i.prItemId === prItemId)
+ }
+
+ // 접힘 상태 토글
+ const toggleVendor = (qid: number) => {
+ setCollapsedVendors((prev) => ({
+ ...prev,
+ [qid]: !prev[qid],
+ }))
+ }
+
+ return (
+ <Dialog open={open} onOpenChange={onOpenChange}>
+ {/* 다이얼로그 자체는 max-h, max-w 설정, 내부에 스크롤 컨테이너를 둠 */}
+ <DialogContent className="max-w-[90vw] lg:max-w-5xl max-h-[90vh]" style={{ maxWidth: '90vw', maxHeight: '90vh' }}>
+ <DialogHeader>
+ <DialogTitle>벤더 견적 비교</DialogTitle>
+ <DialogDescription>
+ {selectedRfq
+ ? `RFQ ${selectedRfq.rfqCode} - ${selectedRfq.itemName || ""}`
+ : ""}
+ </DialogDescription>
+ </DialogHeader>
+
+ {isLoading ? (
+ <div className="space-y-4">
+ <Skeleton className="h-8 w-1/2" />
+ <Skeleton className="h-48 w-full" />
+ </div>
+ ) : quotations.length === 0 ? (
+ <div className="py-8 text-center text-muted-foreground">
+ 제출된(Submitted) 견적이 없습니다
+ </div>
+ ) : (
+ <Tabs value={activeTab} onValueChange={setActiveTab} className="w-full">
+ <TabsList className="grid w-full grid-cols-2">
+ <TabsTrigger value="summary">견적 요약 비교</TabsTrigger>
+ <TabsTrigger value="items">아이템별 비교</TabsTrigger>
+ </TabsList>
+
+ {/* ======================== 요약 비교 탭 ======================== */}
+ <TabsContent value="summary" className="mt-4">
+ {/*
+ table-fixed + 가로 너비를 크게 잡아줌 (예: w-[1200px])
+ -> 컨테이너보다 넓으면 수평 스크롤 발생.
+ */}
+ <div className="border rounded-md max-h-[60vh] overflow-auto">
+ <table className="table-fixed w-full border-collapse">
+ <thead className="sticky top-0 bg-background z-10">
+ <TableRow>
+ <TableHead
+ className="sticky left-0 top-0 z-20 bg-background p-2"
+ >
+ 항목
+ </TableHead>
+ {quotations.map((q) => (
+ <TableHead key={q.id} className="p-2 text-center whitespace-nowrap">
+ {q.vendorName || `벤더 ID: ${q.vendorId}`}
+ </TableHead>
+ ))}
+ </TableRow>
+ </thead>
+ <tbody>
+ {/* 견적 상태 */}
+ <TableRow>
+ <TableCell className="sticky left-0 z-10 bg-muted/30 p-2 font-medium">
+ 견적 상태
+ </TableCell>
+ {quotations.map((q) => (
+ <TableCell key={`status-${q.id}`} className="p-2">
+ <Badge variant={getStatusBadgeVariant(q.status)}>
+ {q.status}
+ </Badge>
+ </TableCell>
+ ))}
+ </TableRow>
+
+ {/* 견적 버전 */}
+ <TableRow>
+ <TableCell className="sticky left-0 z-10 bg-muted/30 p-2 font-medium">
+ 견적 버전
+ </TableCell>
+ {quotations.map((q) => (
+ <TableCell key={`version-${q.id}`} className="p-2">
+ v{q.quotationVersion}
+ </TableCell>
+ ))}
+ </TableRow>
+
+ {/* 총 금액 */}
+ <TableRow>
+ <TableCell className="sticky left-0 z-10 bg-muted/30 p-2 font-medium">
+ 총 금액
+ </TableCell>
+ {quotations.map((q) => (
+ <TableCell key={`total-${q.id}`} className="p-2 font-semibold">
+ {formatCurrency(Number(q.totalPrice), q.currency)}
+ </TableCell>
+ ))}
+ </TableRow>
+
+ {/* 소계 */}
+ <TableRow>
+ <TableCell className="sticky left-0 z-10 bg-muted/30 p-2 font-medium">
+ 소계
+ </TableCell>
+ {quotations.map((q) => (
+ <TableCell key={`subtotal-${q.id}`} className="p-2">
+ {formatCurrency(Number(q.subTotal), q.currency)}
+ </TableCell>
+ ))}
+ </TableRow>
+
+ {/* 세금 */}
+ <TableRow>
+ <TableCell className="sticky left-0 z-10 bg-muted/30 p-2 font-medium">
+ 세금
+ </TableCell>
+ {quotations.map((q) => (
+ <TableCell key={`tax-${q.id}`} className="p-2">
+ {formatCurrency(Number(q.taxTotal), q.currency)}
+ </TableCell>
+ ))}
+ </TableRow>
+
+ {/* 할인 */}
+ <TableRow>
+ <TableCell className="sticky left-0 z-10 bg-muted/30 p-2 font-medium">
+ 할인
+ </TableCell>
+ {quotations.map((q) => (
+ <TableCell key={`discount-${q.id}`} className="p-2">
+ {formatCurrency(Number(q.discountTotal), q.currency)}
+ </TableCell>
+ ))}
+ </TableRow>
+
+ {/* 통화 */}
+ <TableRow>
+ <TableCell className="sticky left-0 z-10 bg-muted/30 p-2 font-medium">
+ 통화
+ </TableCell>
+ {quotations.map((q) => (
+ <TableCell key={`currency-${q.id}`} className="p-2">
+ {q.currency}
+ </TableCell>
+ ))}
+ </TableRow>
+
+ {/* 유효기간 */}
+ <TableRow>
+ <TableCell className="sticky left-0 z-10 bg-muted/30 p-2 font-medium">
+ 유효 기간
+ </TableCell>
+ {quotations.map((q) => (
+ <TableCell key={`valid-${q.id}`} className="p-2">
+ {formatDate(q.validUntil, "KR")}
+ </TableCell>
+ ))}
+ </TableRow>
+
+ {/* 예상 배송일 */}
+ <TableRow>
+ <TableCell className="sticky left-0 z-10 bg-muted/30 p-2 font-medium">
+ 예상 배송일
+ </TableCell>
+ {quotations.map((q) => (
+ <TableCell key={`delivery-${q.id}`} className="p-2">
+ {formatDate(q.estimatedDeliveryDate, "KR")}
+ </TableCell>
+ ))}
+ </TableRow>
+
+ {/* 지불 조건 */}
+ <TableRow>
+ <TableCell className="sticky left-0 z-10 bg-muted/30 p-2 font-medium">
+ 지불 조건
+ </TableCell>
+ {quotations.map((q) => (
+ <TableCell key={`payment-${q.id}`} className="p-2">
+ {q.paymentTermsDescription || q.paymentTermsCode}
+ </TableCell>
+ ))}
+ </TableRow>
+
+ {/* 인코텀즈 */}
+ <TableRow>
+ <TableCell className="sticky left-0 z-10 bg-muted/30 p-2 font-medium">
+ 인코텀즈
+ </TableCell>
+ {quotations.map((q) => (
+ <TableCell key={`incoterms-${q.id}`} className="p-2">
+ {q.incotermsDescription || q.incotermsCode}
+ {q.incotermsDetail && (
+ <div className="text-xs text-muted-foreground mt-1">
+ {q.incotermsDetail}
+ </div>
+ )}
+ </TableCell>
+ ))}
+ </TableRow>
+
+ {/* 제출일 */}
+ <TableRow>
+ <TableCell className="sticky left-0 z-10 bg-muted/30 p-2 font-medium">
+ 제출일
+ </TableCell>
+ {quotations.map((q) => (
+ <TableCell key={`submitted-${q.id}`} className="p-2">
+ {formatDate(q.submittedAt, "KR")}
+ </TableCell>
+ ))}
+ </TableRow>
+
+ {/* 비고 */}
+ <TableRow>
+ <TableCell className="sticky left-0 z-10 bg-muted/30 p-2 font-medium">
+ 비고
+ </TableCell>
+ {quotations.map((q) => (
+ <TableCell
+ key={`remark-${q.id}`}
+ className="p-2 whitespace-pre-wrap"
+ >
+ {q.remark || "-"}
+ </TableCell>
+ ))}
+ </TableRow>
+ </tbody>
+ </table>
+ </div>
+ </TabsContent>
+
+ {/* ====================== 아이템별 비교 탭 ====================== */}
+ <TabsContent value="items" className="mt-4">
+ {/* 컨테이너에 테이블 관련 클래스 직접 적용 */}
+ <div className="border rounded-md max-h-[60vh] overflow-y-auto overflow-x-auto" >
+ <div className="min-w-full w-max" style={{ maxWidth: '70vw' }}>
+ <table className="w-full border-collapse">
+ <thead className="sticky top-0 bg-background z-10">
+ {/* 첫 번째 헤더 행 */}
+ <tr>
+ {/* 첫 행: 자재(코드) 컬럼 */}
+ <th
+ rowSpan={2}
+ className="sticky left-0 top-0 z-20 p-2 border border-gray-200 text-left"
+ style={{
+ width: '250px',
+ minWidth: '250px',
+ backgroundColor: 'white',
+ }}
+ >
+ 자재 (코드)
+ </th>
+
+ {/* 벤더 헤더 (접힘/펼침) */}
+ {quotations.map((q, index) => {
+ const collapsed = collapsedVendors[q.id]
+ // 접힌 상태면 1칸, 펼친 상태면 6칸
+ return (
+ <th
+ key={q.id}
+ className="p-2 text-center whitespace-nowrap border border-gray-200"
+ colSpan={collapsed ? 1 : 6}
+ style={{
+ borderRight: index < quotations.length - 1 ? '1px solid #e5e7eb' : '',
+ backgroundColor: 'white',
+ }}
+ >
+ {/* + / - 버튼 */}
+ <div className="flex items-center gap-2 justify-center">
+ <Button
+ variant="ghost"
+ size="sm"
+ className="h-7 w-7 p-1"
+ onClick={() => toggleVendor(q.id)}
+ >
+ {collapsed ? <Plus size={16} /> : <Minus size={16} />}
+ </Button>
+ <span>{q.vendorName || `벤더 ID: ${q.vendorId}`}</span>
+ </div>
+ </th>
+ )
+ })}
+ </tr>
+
+ {/* 두 번째 헤더 행 - 하위 컬럼들 */}
+ <tr className="border-b border-b-gray-200">
+ {/* 펼쳐진 벤더의 하위 컬럼들만 표시 */}
+ {quotations.flatMap((q, qIndex) => {
+ // 접힌 상태면 추가 헤더 없음
+ if (collapsedVendors[q.id]) {
+ return [
+ <th
+ key={`${q.id}-collapsed`}
+ className="p-2 text-center whitespace-nowrap border border-gray-200"
+ style={{ backgroundColor: 'white' }}
+ >
+ 총액
+ </th>
+ ];
+ }
+
+ // 펼친 상태면 6개 컬럼 표시
+ const columns = [
+ { key: 'unitprice', label: '단가' },
+ { key: 'totalprice', label: '총액' },
+ { key: 'tax', label: '세금' },
+ { key: 'discount', label: '할인' },
+ { key: 'leadtime', label: '리드타임' },
+ { key: 'alternative', label: '대체품' },
+ ];
+
+ return columns.map((col, colIndex) => {
+ const isFirstInGroup = colIndex === 0;
+ const isLastInGroup = colIndex === columns.length - 1;
+
+ return (
+ <th
+ key={`${q.id}-${col.key}`}
+ className={`p-2 text-center whitespace-nowrap border border-gray-200 ${
+ isFirstInGroup ? 'border-l border-l-gray-200' : ''
+ } ${
+ isLastInGroup ? 'border-r border-r-gray-200' : ''
+ }`}
+ style={{ backgroundColor: 'white' }}
+ >
+ {col.label}
+ </th>
+ );
+ });
+ })}
+ </tr>
+ </thead>
+
+ {/* 테이블 바디 */}
+ <tbody>
+ {allItemIds.map((itemId) => {
+ // 자재 기본 정보는 첫 번째 벤더 아이템 기준
+ const firstQid = quotations[0]?.id
+ const sampleItem = firstQid
+ ? findItemByQuotationId(itemId, firstQid)
+ : undefined
+
+ return (
+ <tr key={itemId} className="border-b border-gray-100">
+ {/* 자재 (코드) 셀 */}
+ <td
+ className="sticky left-0 z-10 p-2 align-top border-r border-gray-100"
+ style={{
+ width: '250px',
+ minWidth: '250px',
+ backgroundColor: 'white',
+ }}
+ >
+ {sampleItem?.materialDescription || sampleItem?.materialCode || ""}
+ {sampleItem && (
+ <div className="text-xs text-muted-foreground mt-1">
+ 코드: {sampleItem.materialCode} | 수량:{" "}
+ {sampleItem.quantity} {sampleItem.uom}
+ </div>
+ )}
+ </td>
+
+ {/* 벤더별 아이템 데이터 */}
+ {quotations.flatMap((q, qIndex) => {
+ const collapsed = collapsedVendors[q.id]
+ const itemData = findItemByQuotationId(itemId, q.id)
+
+ // 접힌 상태면 총액만 표시
+ if (collapsed) {
+ return [
+ <td
+ key={`${q.id}-collapsed`}
+ className="p-2 text-center text-sm font-medium whitespace-nowrap border-r border-gray-100"
+ >
+ {itemData
+ ? formatCurrency(Number(itemData.totalPrice), itemData.currency)
+ : "N/A"}
+ </td>
+ ];
+ }
+
+ // 펼친 상태 - 아이템 없음
+ if (!itemData) {
+ return [
+ <td
+ key={`${q.id}-empty`}
+ colSpan={6}
+ className="p-2 text-center text-sm border-r border-gray-100"
+ >
+ 없음
+ </td>
+ ];
+ }
+
+ // 펼친 상태 - 모든 컬럼 표시
+ const columns = [
+ { key: 'unitprice', render: () => formatCurrency(Number(itemData.unitPrice), itemData.currency), align: 'right' },
+ { key: 'totalprice', render: () => formatCurrency(Number(itemData.totalPrice), itemData.currency), align: 'right', bold: true },
+ { key: 'tax', render: () => itemData.taxRate ? `${itemData.taxRate}% (${formatCurrency(Number(itemData.taxAmount), itemData.currency)})` : "-", align: 'right' },
+ { key: 'discount', render: () => itemData.discountRate ? `${itemData.discountRate}% (${formatCurrency(Number(itemData.discountAmount), itemData.currency)})` : "-", align: 'right' },
+ { key: 'leadtime', render: () => itemData.leadTimeInDays ? `${itemData.leadTimeInDays}일` : "-", align: 'center' },
+ { key: 'alternative', render: () => itemData.isAlternative ? "대체품" : "표준품", align: 'center' },
+ ];
+
+ return columns.map((col, colIndex) => {
+ const isFirstInGroup = colIndex === 0;
+ const isLastInGroup = colIndex === columns.length - 1;
+
+ return (
+ <td
+ key={`${q.id}-${col.key}`}
+ className={`p-2 text-${col.align} ${col.bold ? 'font-semibold' : ''} ${
+ isFirstInGroup ? 'border-l border-l-gray-100' : ''
+ } ${
+ isLastInGroup ? 'border-r border-r-gray-100' : 'border-r border-gray-100'
+ }`}
+ >
+ {col.render()}
+ </td>
+ );
+ });
+ })}
+ </tr>
+ );
+ })}
+
+ {/* 아이템이 전혀 없는 경우 */}
+ {allItemIds.length === 0 && (
+ <tr>
+ <td
+ colSpan={100} // 충분히 큰 수로 설정해 모든 컬럼을 커버
+ className="text-center p-4 border border-gray-100"
+ >
+ 아이템 정보가 없습니다
+ </td>
+ </tr>
+ )}
+ </tbody>
+ </table>
+ </div>
+ </div>
+ </TabsContent>
+ </Tabs>
+ )}
+
+ <DialogFooter>
+ <Button variant="outline" onClick={() => onOpenChange(false)}>
+ 닫기
+ </Button>
+ </DialogFooter>
+ </DialogContent>
+ </Dialog>
+ )
+}