summaryrefslogtreecommitdiff
path: root/lib/techsales-rfq/table/detail-table
diff options
context:
space:
mode:
Diffstat (limited to 'lib/techsales-rfq/table/detail-table')
-rw-r--r--lib/techsales-rfq/table/detail-table/add-vendor-dialog.tsx357
-rw-r--r--lib/techsales-rfq/table/detail-table/delete-vendor-dialog.tsx150
-rw-r--r--lib/techsales-rfq/table/detail-table/rfq-detail-column.tsx291
-rw-r--r--lib/techsales-rfq/table/detail-table/rfq-detail-table.tsx654
-rw-r--r--lib/techsales-rfq/table/detail-table/update-vendor-sheet.tsx449
-rw-r--r--lib/techsales-rfq/table/detail-table/vendor-communication-drawer.tsx521
-rw-r--r--lib/techsales-rfq/table/detail-table/vendor-quotation-comparison-dialog.tsx340
7 files changed, 2762 insertions, 0 deletions
diff --git a/lib/techsales-rfq/table/detail-table/add-vendor-dialog.tsx b/lib/techsales-rfq/table/detail-table/add-vendor-dialog.tsx
new file mode 100644
index 00000000..b66f4d77
--- /dev/null
+++ b/lib/techsales-rfq/table/detail-table/add-vendor-dialog.tsx
@@ -0,0 +1,357 @@
+"use client"
+
+import * as React from "react"
+import { useState, useEffect, useCallback } from "react"
+import { useForm } from "react-hook-form"
+import { zodResolver } from "@hookform/resolvers/zod"
+import { z } from "zod"
+import { toast } from "sonner"
+import { Check, X, Search, Loader2 } from "lucide-react"
+import { useSession } from "next-auth/react"
+
+import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog"
+import { Button } from "@/components/ui/button"
+import { Form, FormField, FormItem, FormLabel, FormMessage } from "@/components/ui/form"
+import { Input } from "@/components/ui/input"
+import { ScrollArea } from "@/components/ui/scroll-area"
+import { Badge } from "@/components/ui/badge"
+import { addVendorsToTechSalesRfq } from "@/lib/techsales-rfq/service"
+import { searchVendors } from "@/lib/vendors/service"
+
+// 폼 유효성 검증 스키마 - 간단화
+const vendorFormSchema = z.object({
+ vendorIds: z.array(z.number()).min(1, "최소 하나의 벤더를 선택해주세요"),
+})
+
+type VendorFormValues = z.infer<typeof vendorFormSchema>
+
+// 기술영업 RFQ 타입 정의
+type TechSalesRfq = {
+ id: number
+ rfqCode: string | null
+ status: string
+ [key: string]: any // eslint-disable-line @typescript-eslint/no-explicit-any
+}
+
+// 벤더 검색 결과 타입 (searchVendors 함수 반환 타입과 일치)
+type VendorSearchResult = {
+ id: number
+ vendorName: string
+ vendorCode: string | null
+ status: string
+ country: string | null
+}
+
+interface AddVendorDialogProps {
+ open: boolean
+ onOpenChange: (open: boolean) => void
+ selectedRfq: TechSalesRfq | null
+ onSuccess?: () => void
+ existingVendorIds?: number[]
+}
+
+export function AddVendorDialog({
+ open,
+ onOpenChange,
+ selectedRfq,
+ onSuccess,
+ existingVendorIds = [],
+}: AddVendorDialogProps) {
+ const { data: session } = useSession()
+ const [isSubmitting, setIsSubmitting] = useState(false)
+ const [searchTerm, setSearchTerm] = useState("")
+ const [searchResults, setSearchResults] = useState<VendorSearchResult[]>([])
+ const [isSearching, setIsSearching] = useState(false)
+ const [hasSearched, setHasSearched] = useState(false)
+ // 선택된 벤더들을 별도로 관리하여 검색과 독립적으로 유지
+ const [selectedVendorData, setSelectedVendorData] = useState<VendorSearchResult[]>([])
+
+ const form = useForm<VendorFormValues>({
+ resolver: zodResolver(vendorFormSchema),
+ defaultValues: {
+ vendorIds: [],
+ },
+ })
+
+ const selectedVendorIds = form.watch("vendorIds")
+
+ // 검색 함수 (디바운스 적용)
+ const searchVendorsDebounced = useCallback(
+ async (term: string) => {
+ if (!term.trim()) {
+ setSearchResults([])
+ setHasSearched(false)
+ return
+ }
+
+ setIsSearching(true)
+ try {
+ const results = await searchVendors(term, 100)
+ // 이미 추가된 벤더 제외
+ const filteredResults = results.filter(vendor => !existingVendorIds.includes(vendor.id))
+ setSearchResults(filteredResults)
+ setHasSearched(true)
+ } catch (error) {
+ console.error("벤더 검색 오류:", error)
+ toast.error("벤더 검색 중 오류가 발생했습니다")
+ setSearchResults([])
+ } finally {
+ setIsSearching(false)
+ }
+ },
+ [existingVendorIds]
+ )
+
+ // 검색어 변경 시 디바운스 적용
+ useEffect(() => {
+ const timer = setTimeout(() => {
+ searchVendorsDebounced(searchTerm)
+ }, 300)
+
+ return () => clearTimeout(timer)
+ }, [searchTerm, searchVendorsDebounced])
+
+ // 벤더 선택/해제 핸들러
+ const handleVendorToggle = (vendor: VendorSearchResult) => {
+ const currentIds = form.getValues("vendorIds")
+ const isSelected = currentIds.includes(vendor.id)
+
+ if (isSelected) {
+ // 선택 해제
+ const newIds = currentIds.filter(id => id !== vendor.id)
+ const newSelectedData = selectedVendorData.filter(v => v.id !== vendor.id)
+ form.setValue("vendorIds", newIds, { shouldValidate: true })
+ setSelectedVendorData(newSelectedData)
+ } else {
+ // 선택 추가
+ const newIds = [...currentIds, vendor.id]
+ const newSelectedData = [...selectedVendorData, vendor]
+ form.setValue("vendorIds", newIds, { shouldValidate: true })
+ setSelectedVendorData(newSelectedData)
+ }
+ }
+
+ // 선택된 벤더 제거 핸들러
+ const handleRemoveVendor = (vendorId: number) => {
+ const currentIds = form.getValues("vendorIds")
+ const newIds = currentIds.filter(id => id !== vendorId)
+ const newSelectedData = selectedVendorData.filter(v => v.id !== vendorId)
+ form.setValue("vendorIds", newIds, { shouldValidate: true })
+ setSelectedVendorData(newSelectedData)
+ }
+
+ // 폼 제출 핸들러
+ async function onSubmit(values: VendorFormValues) {
+ if (!selectedRfq) {
+ toast.error("선택된 RFQ가 없습니다")
+ return
+ }
+
+ if (!session?.user?.id) {
+ toast.error("로그인이 필요합니다")
+ return
+ }
+
+ try {
+ setIsSubmitting(true)
+
+ // 서비스 함수 호출
+ const result = await addVendorsToTechSalesRfq({
+ rfqId: selectedRfq.id,
+ vendorIds: values.vendorIds,
+ createdBy: Number(session.user.id),
+ })
+
+ if (result.error) {
+ toast.error(result.error)
+ } else {
+ const successMessage = `${result.successCount}개의 벤더가 성공적으로 추가되었습니다`
+ const errorMessage = result.errorCount && result.errorCount > 0 ? ` (${result.errorCount}개 실패)` : ""
+ toast.success(successMessage + errorMessage)
+
+ onOpenChange(false)
+ form.reset()
+ setSearchTerm("")
+ setSearchResults([])
+ setHasSearched(false)
+ setSelectedVendorData([])
+ onSuccess?.()
+ }
+ } catch (error) {
+ console.error("벤더 추가 오류:", error)
+ toast.error("벤더 추가 중 오류가 발생했습니다")
+ } finally {
+ setIsSubmitting(false)
+ }
+ }
+
+ // 다이얼로그 닫기 시 폼 리셋
+ React.useEffect(() => {
+ if (!open) {
+ form.reset()
+ setSearchTerm("")
+ setSearchResults([])
+ setHasSearched(false)
+ setSelectedVendorData([])
+ }
+ }, [open, form])
+
+ return (
+ <Dialog open={open} onOpenChange={onOpenChange}>
+ <DialogContent className="sm:max-w-[700px] max-h-[80vh] flex flex-col">
+ {/* 헤더 */}
+ <DialogHeader>
+ <DialogTitle>벤더 추가</DialogTitle>
+ <DialogDescription>
+ {selectedRfq ? (
+ <>
+ <span className="font-medium">{selectedRfq.rfqCode}</span> RFQ에 벤더를 추가합니다.
+ </>
+ ) : (
+ "RFQ에 벤더를 추가합니다."
+ )}
+ </DialogDescription>
+ </DialogHeader>
+
+ {/* 콘텐츠 */}
+ <div className="flex-1 overflow-y-auto">
+ <Form {...form}>
+ <form id="vendor-form" onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
+ {/* 벤더 검색 필드 */}
+ <div className="space-y-2">
+ <label className="text-sm font-medium">벤더 검색</label>
+ <div className="relative">
+ <Search className="absolute left-3 top-1/2 transform -translate-y-1/2 h-4 w-4 text-muted-foreground" />
+ <Input
+ placeholder="벤더명 또는 벤더코드로 검색..."
+ value={searchTerm}
+ onChange={(e) => setSearchTerm(e.target.value)}
+ className="pl-10"
+ />
+ {isSearching && (
+ <Loader2 className="absolute right-3 top-1/2 transform -translate-y-1/2 h-4 w-4 animate-spin text-muted-foreground" />
+ )}
+ </div>
+ </div>
+
+ {/* 검색 결과 */}
+ {hasSearched && (
+ <div className="space-y-2">
+ <div className="text-sm font-medium">
+ 검색 결과 ({searchResults.length}개)
+ </div>
+ <ScrollArea className="h-60 border rounded-md">
+ <div className="p-2 space-y-1">
+ {searchResults.length > 0 ? (
+ searchResults.map((vendor) => (
+ <div
+ key={vendor.id}
+ className={`flex items-center space-x-2 p-2 rounded-md cursor-pointer hover:bg-muted ${
+ selectedVendorIds.includes(vendor.id) ? "bg-muted" : ""
+ }`}
+ onClick={() => handleVendorToggle(vendor)}
+ >
+ <div className="flex items-center space-x-2 flex-1">
+ <Check
+ className={`h-4 w-4 ${
+ selectedVendorIds.includes(vendor.id)
+ ? "opacity-100"
+ : "opacity-0"
+ }`}
+ />
+ <div className="flex-1">
+ <div className="font-medium">{vendor.vendorName}</div>
+ <div className="text-sm text-muted-foreground">
+ {vendor.vendorCode || 'N/A'} {vendor.country && `• ${vendor.country}`}
+ </div>
+ </div>
+ </div>
+ </div>
+ ))
+ ) : (
+ <div className="text-center py-8 text-muted-foreground">
+ 검색 결과가 없습니다
+ </div>
+ )}
+ </div>
+ </ScrollArea>
+ </div>
+ )}
+
+ {/* 검색 안내 메시지 */}
+ {!hasSearched && !searchTerm && (
+ <div className="text-center py-8 text-muted-foreground border rounded-md">
+ 벤더명 또는 벤더코드를 입력하여 검색해주세요
+ </div>
+ )}
+
+ {/* 선택된 벤더 목록 - 하단에 항상 표시 */}
+ <FormField
+ control={form.control}
+ name="vendorIds"
+ render={() => (
+ <FormItem>
+ <div className="space-y-2">
+ <FormLabel>선택된 벤더 ({selectedVendorData.length}개)</FormLabel>
+ <div className="min-h-[60px] p-3 border rounded-md bg-muted/50">
+ {selectedVendorData.length > 0 ? (
+ <div className="flex flex-wrap gap-2">
+ {selectedVendorData.map((vendor) => (
+ <Badge
+ key={vendor.id}
+ variant="secondary"
+ className="flex items-center gap-1"
+ >
+ {vendor.vendorName} ({vendor.vendorCode || 'N/A'})
+ <X
+ className="h-3 w-3 cursor-pointer hover:text-destructive"
+ onClick={() => handleRemoveVendor(vendor.id)}
+ />
+ </Badge>
+ ))}
+ </div>
+ ) : (
+ <div className="flex items-center justify-center h-full text-sm text-muted-foreground">
+ 선택된 벤더가 없습니다
+ </div>
+ )}
+ </div>
+ </div>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+
+ {/* 안내 메시지 */}
+ <div className="text-sm text-muted-foreground bg-muted p-3 rounded-md">
+ {/* <p>• 검색은 ACTIVE 상태의 벤더만 대상으로 합니다.</p> */}
+ <p>• 선택된 벤더들은 Draft 상태로 추가됩니다.</p>
+ <p>• 벤더별 견적 정보는 추가 후 개별적으로 입력할 수 있습니다.</p>
+ <p>• 이미 추가된 벤더는 검색 결과에서 체크됩니다.</p>
+ </div>
+ </form>
+ </Form>
+ </div>
+
+ {/* 푸터 */}
+ <DialogFooter>
+ <Button
+ type="button"
+ variant="outline"
+ onClick={() => onOpenChange(false)}
+ disabled={isSubmitting}
+ >
+ 취소
+ </Button>
+ <Button
+ type="submit"
+ form="vendor-form"
+ disabled={isSubmitting || selectedVendorIds.length === 0}
+ >
+ {isSubmitting ? "처리 중..." : `${selectedVendorIds.length}개 벤더 추가`}
+ </Button>
+ </DialogFooter>
+ </DialogContent>
+ </Dialog>
+ )
+} \ No newline at end of file
diff --git a/lib/techsales-rfq/table/detail-table/delete-vendor-dialog.tsx b/lib/techsales-rfq/table/detail-table/delete-vendor-dialog.tsx
new file mode 100644
index 00000000..d7e3403b
--- /dev/null
+++ b/lib/techsales-rfq/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 DeleteVendorDialog({
+ 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.id)
+
+ 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/techsales-rfq/table/detail-table/rfq-detail-column.tsx b/lib/techsales-rfq/table/detail-table/rfq-detail-column.tsx
new file mode 100644
index 00000000..c4a7edde
--- /dev/null
+++ b/lib/techsales-rfq/table/detail-table/rfq-detail-column.tsx
@@ -0,0 +1,291 @@
+"use client"
+
+import * as React from "react"
+import type { ColumnDef, Row } from "@tanstack/react-table";
+import { formatDate } from "@/lib/utils"
+import { DataTableColumnHeaderSimple } from "@/components/data-table/data-table-column-simple-header"
+import {
+ DropdownMenu,
+ DropdownMenuContent,
+ DropdownMenuItem,
+ DropdownMenuTrigger,
+} from "@/components/ui/dropdown-menu";
+import { Checkbox } from "@/components/ui/checkbox";
+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";
+}
+
+// 벤더 견적 데이터 타입 정의
+export interface RfqDetailView {
+ id: number
+ rfqId: number
+ vendorId?: number | null
+ vendorName: string | null
+ vendorCode: string | null
+ totalPrice: string | number | null
+ currency: string | null
+ validUntil: Date | null
+ status: string | null
+ remark: string | null
+ submittedAt: Date | null
+ acceptedAt: Date | null
+ rejectionReason: string | null
+ createdAt: Date | null
+ updatedAt: Date | null
+ createdByName: string | null
+}
+
+interface GetColumnsProps<TData> {
+ setRowAction: React.Dispatch<
+ React.SetStateAction<DataTableRowAction<TData> | null>
+ >;
+ unreadMessages?: Record<number, number>; // 읽지 않은 메시지 개수
+}
+
+export function getRfqDetailColumns({
+ setRowAction,
+ unreadMessages = {}
+}: GetColumnsProps<RfqDetailView>): ColumnDef<RfqDetailView>[] {
+ return [
+ {
+ id: "select",
+ header: ({ table }) => (
+ <Checkbox
+ checked={
+ table.getIsAllPageRowsSelected() ||
+ (table.getIsSomePageRowsSelected() && "indeterminate")
+ }
+ onCheckedChange={(value) => table.toggleAllPageRowsSelected(!!value)}
+ aria-label="모두 선택"
+ />
+ ),
+ cell: ({ row }) => {
+ const status = row.original.status;
+ const isDraft = status === "Draft";
+
+ return (
+ <Checkbox
+ checked={row.getIsSelected()}
+ onCheckedChange={(value) => row.toggleSelected(!!value)}
+ disabled={!isDraft}
+ aria-label="행 선택"
+ className={!isDraft ? "opacity-50 cursor-not-allowed" : ""}
+ />
+ );
+ },
+ enableSorting: false,
+ enableHiding: false,
+ size: 40,
+ },
+ {
+ accessorKey: "status",
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="견적 상태" />
+ ),
+ cell: ({ row }) => {
+ const status = row.getValue("status") as string;
+ // 상태에 따른 배지 색상 설정
+ let variant: "default" | "secondary" | "outline" | "destructive" = "outline";
+
+ if (status === "Submitted") {
+ variant = "default"; // 제출됨 - 기본 색상
+ } else if (status === "Accepted") {
+ variant = "secondary"; // 승인됨 - 보조 색상
+ } else if (status === "Rejected") {
+ variant = "destructive"; // 거부됨 - 위험 색상
+ }
+
+ return (
+ <Badge variant={variant}>{status || "Draft"}</Badge>
+ );
+ },
+ 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: "totalPrice",
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="견적 금액" />
+ ),
+ cell: ({ row }) => {
+ const value = row.getValue("totalPrice") as string | number | null;
+ const currency = row.getValue("currency") as string | null;
+
+ if (value === null || value === undefined) return "-";
+
+ // 숫자로 변환 시도
+ const numValue = typeof value === 'string' ? parseFloat(value) : value;
+
+ return (
+ <div className="font-medium">
+ {isNaN(numValue) ? value : numValue.toLocaleString()} {currency}
+ </div>
+ );
+ },
+ meta: {
+ excelHeader: "견적 금액"
+ },
+ enableResizing: true,
+ size: 140,
+ },
+ {
+ accessorKey: "currency",
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="통화" />
+ ),
+ cell: ({ row }) => <div>{row.getValue("currency")}</div>,
+ meta: {
+ excelHeader: "통화"
+ },
+ enableResizing: true,
+ size: 80,
+ },
+ {
+ accessorKey: "validUntil",
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="유효기간" />
+ ),
+ cell: ({ cell }) => {
+ const value = cell.getValue() as Date | null;
+ return value ? formatDate(value, "KR") : "-";
+ },
+ meta: {
+ excelHeader: "유효기간"
+ },
+ enableResizing: true,
+ size: 120,
+ },
+ {
+ accessorKey: "submittedAt",
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="제출일" />
+ ),
+ cell: ({ cell }) => {
+ const value = cell.getValue() as Date | null;
+ return value ? formatDate(value, "KR") : "-";
+ },
+ meta: {
+ excelHeader: "제출일"
+ },
+ enableResizing: true,
+ size: 120,
+ },
+ {
+ accessorKey: "createdByName",
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="등록자" />
+ ),
+ cell: ({ row }) => <div>{row.getValue("createdByName")}</div>,
+ meta: {
+ excelHeader: "등록자"
+ },
+ enableResizing: true,
+ size: 120,
+ },
+ {
+ accessorKey: "remark",
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="비고" />
+ ),
+ cell: ({ row }) => <div>{row.getValue("remark") || "-"}</div>,
+ meta: {
+ excelHeader: "비고"
+ },
+ enableResizing: true,
+ size: 200,
+ },
+ {
+ id: "actions",
+ header: () => <div className="text-right">동작</div>,
+ cell: function Cell({ row }) {
+ const vendorId = row.original.vendorId;
+ const unreadCount = vendorId ? unreadMessages[vendorId] || 0 : 0;
+
+ return (
+ <div className="text-right flex items-center justify-end gap-1">
+ {/* 커뮤니케이션 버튼 */}
+ <div className="relative">
+ <Button
+ variant="ghost"
+ size="sm"
+ className="h-8 w-8 p-0"
+ onClick={() => setRowAction({ row, type: "communicate" })}
+ title="벤더와 커뮤니케이션"
+ >
+ <MessageCircle className="h-4 w-4" />
+ </Button>
+ {unreadCount > 0 && (
+ <Badge
+ variant="destructive"
+ className="absolute -top-1 -right-1 h-4 w-4 p-0 text-xs flex items-center justify-center"
+ >
+ {unreadCount > 9 ? '9+' : unreadCount}
+ </Badge>
+ )}
+ </div>
+
+ {/* 기존 드롭다운 메뉴 */}
+ <DropdownMenu>
+ <DropdownMenuTrigger asChild>
+ <Button
+ variant="ghost"
+ className="flex h-8 w-8 p-0 data-[state=open]:bg-muted"
+ >
+ <Ellipsis className="h-4 w-4" />
+ <span className="sr-only">메뉴 열기</span>
+ </Button>
+ </DropdownMenuTrigger>
+ <DropdownMenuContent align="end" className="w-[160px]">
+ <DropdownMenuItem
+ onClick={() => setRowAction({ row, type: "update" })}
+ >
+ 벤더 수정
+ </DropdownMenuItem>
+ <DropdownMenuItem
+ onClick={() => setRowAction({ row, type: "delete" })}
+ className="text-destructive focus:text-destructive"
+ >
+ 벤더 제거
+ </DropdownMenuItem>
+ </DropdownMenuContent>
+ </DropdownMenu>
+ </div>
+ );
+ },
+ enableResizing: false,
+ size: 80,
+ },
+ ];
+} \ No newline at end of file
diff --git a/lib/techsales-rfq/table/detail-table/rfq-detail-table.tsx b/lib/techsales-rfq/table/detail-table/rfq-detail-table.tsx
new file mode 100644
index 00000000..4f8ac37b
--- /dev/null
+++ b/lib/techsales-rfq/table/detail-table/rfq-detail-table.tsx
@@ -0,0 +1,654 @@
+"use client"
+
+import * as React from "react"
+import { useEffect, useState, useCallback, useMemo } from "react"
+import {
+ DataTableRowAction,
+ getRfqDetailColumns,
+ RfqDetailView
+} from "./rfq-detail-column"
+import { toast } from "sonner"
+
+import { Skeleton } from "@/components/ui/skeleton"
+import { Badge } from "@/components/ui/badge"
+import { Button } from "@/components/ui/button"
+import { Loader2, UserPlus, BarChart2, Send, Trash2, MessageCircle } from "lucide-react"
+import { ClientDataTable } from "@/components/client-data-table/data-table"
+import { AddVendorDialog } from "./add-vendor-dialog"
+import { DeleteVendorDialog } from "./delete-vendor-dialog"
+import { UpdateVendorSheet } from "./update-vendor-sheet"
+import { VendorCommunicationDrawer } from "./vendor-communication-drawer"
+import { VendorQuotationComparisonDialog } from "./vendor-quotation-comparison-dialog"
+
+// 기본적인 RFQ 타입 정의
+interface TechSalesRfq {
+ id: number
+ rfqCode: string | null
+ status: string
+ materialCode?: string | null
+ itemName?: string | null
+ remark?: string | null
+ rfqSendDate?: Date | null
+ dueDate?: Date | null
+ createdByName?: string | null
+ // 필요에 따라 다른 필드들 추가
+ [key: string]: any // eslint-disable-line @typescript-eslint/no-explicit-any
+}
+
+// 프로퍼티 정의
+interface RfqDetailTablesProps {
+ selectedRfq: TechSalesRfq | null
+ maxHeight?: string | number
+}
+
+// 데이터 타입 정의
+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;
+}
+
+export function RfqDetailTables({ selectedRfq, maxHeight }: RfqDetailTablesProps) {
+ // console.log("selectedRfq", selectedRfq)
+
+ // 상태 관리
+ const [isLoading, setIsLoading] = 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 [comparisonDialogOpen, setComparisonDialogOpen] = useState(false)
+
+ // 테이블 선택 상태 관리
+ const [selectedRows, setSelectedRows] = useState<RfqDetailView[]>([])
+ const [isSendingRfq, setIsSendingRfq] = useState(false)
+ const [isDeletingVendors, setIsDeletingVendors] = useState(false)
+
+ // selectedRfq ID 메모이제이션 (객체 참조 변경 방지)
+ const selectedRfqId = useMemo(() => selectedRfq?.id, [selectedRfq?.id])
+
+ // existingVendorIds 메모이제이션
+ const existingVendorIds = useMemo(() => {
+ return details.map(detail => Number(detail.vendorId)).filter(Boolean);
+ }, [details]);
+
+ // 읽지 않은 메시지 로드 함수 메모이제이션
+ const loadUnreadMessages = useCallback(async () => {
+ if (!selectedRfqId) return;
+
+ try {
+ // TODO: 기술영업용 읽지 않은 메시지 수 가져오기 함수 구현 필요
+ // const unreadData = await fetchUnreadMessages(selectedRfqId);
+ // setUnreadMessages(unreadData);
+ setUnreadMessages({});
+ } catch (error) {
+ console.error("읽지 않은 메시지 로드 오류:", error);
+ }
+ }, [selectedRfqId]);
+
+ // 데이터 새로고침 함수 메모이제이션
+ const handleRefreshData = useCallback(async () => {
+ if (!selectedRfqId) return
+
+ try {
+ // 실제 벤더 견적 데이터 다시 로딩
+ const { getTechSalesVendorQuotationsWithJoin } = await import("@/lib/techsales-rfq/service")
+
+ const result = await getTechSalesVendorQuotationsWithJoin({
+ rfqId: selectedRfqId,
+ page: 1,
+ perPage: 1000,
+ })
+
+ // 데이터 변환
+ const transformedData = result.data?.map(item => ({
+ ...item,
+ detailId: item.id,
+ rfqId: selectedRfqId,
+ rfqCode: selectedRfq?.rfqCode || null,
+ vendorId: item.vendorId ? Number(item.vendorId) : undefined,
+ })) || []
+
+ setDetails(transformedData)
+
+ // 읽지 않은 메시지 개수 업데이트
+ await loadUnreadMessages();
+
+ toast.success("데이터를 성공적으로 새로고침했습니다")
+ } catch (error) {
+ console.error("데이터 새로고침 오류:", error)
+ toast.error("데이터를 새로고침하는 중 오류가 발생했습니다")
+ }
+ }, [selectedRfqId, selectedRfq?.rfqCode, loadUnreadMessages])
+
+ // 벤더 추가 핸들러 메모이제이션
+ const handleAddVendor = useCallback(async () => {
+ try {
+ setIsAdddialogLoading(true)
+
+ // TODO: 기술영업용 벤더, 통화, 지불조건, 인코텀즈 데이터 로드 함수 구현 필요
+ // const [vendorsData, currenciesData, paymentTermsData, incotermsData] = await Promise.all([
+ // fetchVendors(),
+ // fetchCurrencies(),
+ // fetchPaymentTerms(),
+ // fetchIncoterms()
+ // ])
+
+ // 임시 데이터
+ setVendors([])
+ setCurrencies([])
+ setPaymentTerms([])
+ setIncoterms([])
+
+ setVendorDialogOpen(true)
+ } catch (error) {
+ console.error("데이터 로드 오류:", error)
+ toast.error("벤더 정보를 불러오는 중 오류가 발생했습니다")
+ } finally {
+ setIsAdddialogLoading(false)
+ }
+ }, [])
+
+ // RFQ 발송 핸들러 메모이제이션
+ const handleSendRfq = useCallback(async () => {
+ if (selectedRows.length === 0) {
+ toast.warning("발송할 벤더를 선택해주세요.");
+ return;
+ }
+
+ if (!selectedRfqId) {
+ toast.error("선택된 RFQ가 없습니다.");
+ return;
+ }
+
+ try {
+ setIsSendingRfq(true);
+
+ // 기술영업 RFQ 발송 서비스 함수 호출
+ const vendorIds = selectedRows.map(row => row.vendorId).filter(Boolean);
+ const { sendTechSalesRfqToVendors } = await import("@/lib/techsales-rfq/service");
+
+ const result = await sendTechSalesRfqToVendors({
+ rfqId: selectedRfqId,
+ vendorIds: vendorIds as number[]
+ });
+
+ if (result.success) {
+ toast.success(result.message || `${selectedRows.length}개 벤더에게 RFQ가 발송되었습니다.`);
+ } else {
+ toast.error(result.message || "RFQ 발송 중 오류가 발생했습니다.");
+ }
+
+ // 선택 해제
+ setSelectedRows([]);
+
+ // 데이터 새로고침
+ await handleRefreshData();
+
+ } catch (error) {
+ console.error("RFQ 발송 오류:", error);
+ toast.error("RFQ 발송 중 오류가 발생했습니다.");
+ } finally {
+ setIsSendingRfq(false);
+ }
+ }, [selectedRows, selectedRfqId, handleRefreshData]);
+
+ // 벤더 삭제 핸들러 메모이제이션
+ const handleDeleteVendors = useCallback(async () => {
+ if (selectedRows.length === 0) {
+ toast.warning("삭제할 벤더를 선택해주세요.");
+ return;
+ }
+
+ if (!selectedRfqId) {
+ toast.error("선택된 RFQ가 없습니다.");
+ return;
+ }
+
+ try {
+ setIsDeletingVendors(true);
+
+ const vendorIds = selectedRows.map(row => row.vendorId).filter(Boolean) as number[];
+
+ if (vendorIds.length === 0) {
+ toast.error("유효한 벤더 ID가 없습니다.");
+ return;
+ }
+
+ // 서비스 함수 호출
+ const { removeVendorsFromTechSalesRfq } = await import("@/lib/techsales-rfq/service");
+
+ const result = await removeVendorsFromTechSalesRfq({
+ rfqId: selectedRfqId,
+ vendorIds: vendorIds
+ });
+
+ if (result.error) {
+ toast.error(result.error);
+ } else {
+ const successMessage = `${result.successCount}개의 벤더가 성공적으로 삭제되었습니다`;
+ const errorMessage = result.errorCount && result.errorCount > 0 ? ` (${result.errorCount}개 실패)` : "";
+ toast.success(successMessage + errorMessage);
+ }
+
+ // 선택 해제
+ setSelectedRows([]);
+
+ // 데이터 새로고침
+ await handleRefreshData();
+
+ } catch (error) {
+ console.error("벤더 삭제 오류:", error);
+ toast.error("벤더 삭제 중 오류가 발생했습니다.");
+ } finally {
+ setIsDeletingVendors(false);
+ }
+ }, [selectedRows, selectedRfqId, handleRefreshData]);
+
+ // 견적 비교 다이얼로그 열기 핸들러 메모이제이션
+ const handleOpenComparisonDialog = useCallback(() => {
+ // 제출된 견적이 있는 벤더가 최소 1개 이상 있는지 확인
+ const hasSubmittedQuotations = details.some(detail =>
+ detail.status === "Submitted" // RfqDetailView의 실제 필드 사용
+ );
+
+ if (!hasSubmittedQuotations) {
+ toast.warning("제출된 견적이 없습니다.");
+ return;
+ }
+
+ setComparisonDialogOpen(true);
+ }, [details])
+
+ // 칼럼 정의 - unreadMessages 상태 전달 (메모이제이션)
+ const columns = useMemo(() =>
+ getRfqDetailColumns({
+ setRowAction,
+ unreadMessages
+ }), [unreadMessages])
+
+ // 필터 필드 정의 (메모이제이션)
+ const advancedFilterFields = useMemo(
+ () => [
+ {
+ id: "vendorName",
+ label: "벤더명",
+ type: "text",
+ },
+ {
+ id: "vendorCode",
+ label: "벤더 코드",
+ type: "text",
+ },
+ {
+ id: "currency",
+ label: "통화",
+ type: "text",
+ },
+ ],
+ []
+ )
+
+ // 계산된 값들 메모이제이션
+ const totalUnreadMessages = useMemo(() =>
+ Object.values(unreadMessages).reduce((sum, count) => sum + count, 0),
+ [unreadMessages]
+ );
+
+ const vendorsWithQuotations = useMemo(() =>
+ details.filter(detail => detail.status === "Submitted").length,
+ [details]
+ );
+
+ // RFQ ID가 변경될 때 데이터 로드
+ useEffect(() => {
+ async function loadRfqDetails() {
+ if (!selectedRfqId) {
+ setDetails([])
+ return
+ }
+
+ try {
+ setIsLoading(true)
+
+ // 실제 벤더 견적 데이터 로딩
+ const { getTechSalesVendorQuotationsWithJoin } = await import("@/lib/techsales-rfq/service")
+
+ const result = await getTechSalesVendorQuotationsWithJoin({
+ rfqId: selectedRfqId,
+ page: 1,
+ perPage: 1000, // 모든 데이터 가져오기
+ })
+
+ // 데이터 변환 (procurement 패턴에 맞게)
+ const transformedData = result.data?.map(item => ({
+ ...item,
+ detailId: item.id,
+ rfqId: selectedRfqId,
+ rfqCode: selectedRfq?.rfqCode || null,
+ vendorId: item.vendorId ? Number(item.vendorId) : undefined,
+ // 기타 필요한 필드 변환
+ })) || []
+
+ setDetails(transformedData)
+
+ // 읽지 않은 메시지 개수 로드
+ await loadUnreadMessages();
+
+ } catch (error) {
+ console.error("RFQ 디테일 로드 오류:", error)
+ setDetails([])
+ toast.error("RFQ 세부정보를 불러오는 중 오류가 발생했습니다")
+ } finally {
+ setIsLoading(false)
+ }
+ }
+
+ loadRfqDetails()
+ }, [selectedRfqId, selectedRfq?.rfqCode, loadUnreadMessages])
+
+ // 주기적으로 읽지 않은 메시지 갱신 (60초마다) - 메모이제이션된 함수 사용
+ useEffect(() => {
+ if (!selectedRfqId) return;
+
+ const intervalId = setInterval(() => {
+ loadUnreadMessages();
+ }, 60000); // 60초마다 갱신
+
+ return () => clearInterval(intervalId);
+ }, [selectedRfqId, loadUnreadMessages]);
+
+ // rowAction 처리 - procurement 패턴 적용 (메모이제이션)
+ 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);
+
+ // TODO: 필요한 데이터 로드 (벤더, 통화, 지불조건, 인코텀즈)
+ // const [vendorsData, currenciesData, paymentTermsData, incotermsData] = await Promise.all([
+ // fetchVendors(),
+ // fetchCurrencies(),
+ // fetchPaymentTerms(),
+ // fetchIncoterms()
+ // ]);
+
+ // 임시 데이터
+ setVendors([]);
+ setCurrencies([]);
+ setPaymentTerms([]);
+ setIncoterms([]);
+
+ // 이제 데이터가 로드되었으므로 필요한 작업 수행
+ 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])
+
+ // 선택된 행 변경 핸들러 메모이제이션
+ const handleSelectedRowsChange = useCallback((selectedRowsData: RfqDetailView[]) => {
+ setSelectedRows(selectedRowsData);
+ }, []);
+
+ // 커뮤니케이션 드로어 변경 핸들러 메모이제이션
+ const handleCommunicationDrawerChange = useCallback((open: boolean) => {
+ setCommunicationDrawerOpen(open);
+ // 드로어가 닫힐 때 읽지 않은 메시지 개수 갱신
+ if (!open) loadUnreadMessages();
+ }, [loadUnreadMessages]);
+
+ if (!selectedRfq) {
+ return (
+ <div className="flex items-center justify-center h-full 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>
+ )
+ }
+
+ return (
+ <div className="h-full overflow-hidden pt-4">
+ {/* 테이블 또는 빈 상태 표시 */}
+ {details.length > 0 ? (
+ <ClientDataTable
+ columns={columns}
+ data={details}
+ advancedFilterFields={advancedFilterFields}
+ maxHeight={maxHeight}
+ onSelectedRowsChange={handleSelectedRowsChange}
+ >
+ <div className="flex justify-between items-center">
+ <div className="flex items-center gap-2 mr-2">
+ {selectedRows.length > 0 && (
+ <Badge variant="default" className="h-6">
+ {selectedRows.length}개 선택됨
+ </Badge>
+ )}
+ {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">
+ {/* RFQ 발송 버튼 */}
+ <Button
+ variant="outline"
+ size="sm"
+ onClick={handleSendRfq}
+ disabled={selectedRows.length === 0 || isSendingRfq}
+ className="gap-2"
+ >
+ {isSendingRfq ? (
+ <Loader2 className="size-4 animate-spin" aria-hidden="true" />
+ ) : (
+ <Send className="size-4" aria-hidden="true" />
+ )}
+ <span>RFQ 발송</span>
+ </Button>
+
+ {/* 벤더 삭제 버튼 */}
+ <Button
+ variant="outline"
+ size="sm"
+ onClick={handleDeleteVendors}
+ disabled={selectedRows.length === 0 || isDeletingVendors}
+ className="gap-2"
+ >
+ {isDeletingVendors ? (
+ <Loader2 className="size-4 animate-spin" aria-hidden="true" />
+ ) : (
+ <Trash2 className="size-4" aria-hidden="true" />
+ )}
+ <span>벤더 삭제</span>
+ </Button>
+
+ {/* 견적 비교 버튼 */}
+ <Button
+ variant="outline"
+ size="sm"
+ onClick={handleOpenComparisonDialog}
+ className="gap-2"
+ disabled={
+ !selectedRfq ||
+ details.length === 0 ||
+ vendorsWithQuotations === 0
+ }
+ >
+ <BarChart2 className="size-4" aria-hidden="true" />
+ <span>견적 비교/선택</span>
+ </Button>
+
+ {/* 벤더 추가 버튼 */}
+ <Button
+ variant="outline"
+ size="sm"
+ onClick={handleAddVendor}
+ disabled={isAdddialogLoading}
+ className="gap-2"
+ >
+ {isAdddialogLoading ? (
+ <Loader2 className="size-4 animate-spin" aria-hidden="true" />
+ ) : (
+ <UserPlus className="size-4" aria-hidden="true" />
+ )}
+ <span>벤더 추가</span>
+ </Button>
+ </div>
+ </div>
+ </ClientDataTable>
+ ) : (
+ <div className="flex h-full items-center justify-center text-muted-foreground">
+ <div className="text-center">
+ <p className="text-lg font-medium">벤더가 없습니다</p>
+ <p className="text-sm">벤더를 추가하여 RFQ를 시작하세요</p>
+ <Button
+ variant="outline"
+ size="sm"
+ onClick={handleAddVendor}
+ disabled={isAdddialogLoading}
+ className="mt-4 gap-2"
+ >
+ {isAdddialogLoading ? (
+ <Loader2 className="size-4 animate-spin" aria-hidden="true" />
+ ) : (
+ <UserPlus className="size-4" aria-hidden="true" />
+ )}
+ <span>벤더 추가</span>
+ </Button>
+ </div>
+ </div>
+ )}
+
+ {/* 다이얼로그들 */}
+ <AddVendorDialog
+ open={vendorDialogOpen}
+ onOpenChange={setVendorDialogOpen}
+ selectedRfq={selectedRfq}
+ existingVendorIds={existingVendorIds}
+ onSuccess={handleRefreshData}
+ />
+
+ <UpdateVendorSheet
+ open={updateSheetOpen}
+ onOpenChange={setUpdateSheetOpen}
+ detail={selectedDetail}
+ vendors={vendors}
+ currencies={currencies}
+ paymentTerms={paymentTerms}
+ incoterms={incoterms}
+ onSuccess={handleRefreshData}
+ />
+
+ <DeleteVendorDialog
+ open={deleteDialogOpen}
+ onOpenChange={setDeleteDialogOpen}
+ detail={selectedDetail}
+ showTrigger={false}
+ onSuccess={handleRefreshData}
+ />
+
+ {/* 벤더 커뮤니케이션 드로어 */}
+ <VendorCommunicationDrawer
+ open={communicationDrawerOpen}
+ onOpenChange={handleCommunicationDrawerChange}
+ selectedRfq={selectedRfq}
+ selectedVendor={selectedVendor}
+ onSuccess={handleRefreshData}
+ />
+
+ {/* 견적 비교 다이얼로그 */}
+ <VendorQuotationComparisonDialog
+ open={comparisonDialogOpen}
+ onOpenChange={setComparisonDialogOpen}
+ selectedRfq={selectedRfq}
+ />
+ </div>
+ )
+} \ No newline at end of file
diff --git a/lib/techsales-rfq/table/detail-table/update-vendor-sheet.tsx b/lib/techsales-rfq/table/detail-table/update-vendor-sheet.tsx
new file mode 100644
index 00000000..0399f4df
--- /dev/null
+++ b/lib/techsales-rfq/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 UpdateVendorSheet({
+ 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/techsales-rfq/table/detail-table/vendor-communication-drawer.tsx b/lib/techsales-rfq/table/detail-table/vendor-communication-drawer.tsx
new file mode 100644
index 00000000..51ef7b38
--- /dev/null
+++ b/lib/techsales-rfq/table/detail-table/vendor-communication-drawer.tsx
@@ -0,0 +1,521 @@
+"use client"
+
+import * as React from "react"
+import { useState, useEffect, useRef } from "react"
+import { RfqDetailView } from "./rfq-detail-column"
+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: {
+ id: number;
+ rfqCode: string | null;
+ status: string;
+ [key: string]: any; // eslint-disable-line @typescript-eslint/no-explicit-any
+ } | 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 || 0);
+ setComments(commentsData as Comment[]); // 구체적인 타입으로 캐스팅
+
+ // Server Action을 사용하여 읽지 않은 메시지를 읽음 상태로 변경
+ await markMessagesAsRead(selectedRfq.id, selectedVendor.vendorId || 0);
+ } 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/techsales-rfq/table/detail-table/vendor-quotation-comparison-dialog.tsx b/lib/techsales-rfq/table/detail-table/vendor-quotation-comparison-dialog.tsx
new file mode 100644
index 00000000..d58dbd00
--- /dev/null
+++ b/lib/techsales-rfq/table/detail-table/vendor-quotation-comparison-dialog.tsx
@@ -0,0 +1,340 @@
+"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"
+import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle } from "@/components/ui/alert-dialog"
+
+// Lucide 아이콘
+import { Plus, Minus, CheckCircle, Loader2 } from "lucide-react"
+
+import { getTechSalesVendorQuotationsWithJoin } from "@/lib/techsales-rfq/service"
+import { acceptTechSalesVendorQuotationAction } from "@/lib/techsales-rfq/actions"
+import { formatCurrency, formatDate } from "@/lib/utils"
+
+// 기술영업 견적 정보 타입
+interface TechSalesVendorQuotation {
+ id: number
+ rfqId: number
+ vendorId: number
+ vendorName?: string | null
+ totalPrice: string | null
+ currency: string | null
+ validUntil: Date | null
+ status: string
+ remark: string | null
+ submittedAt: Date | null
+ acceptedAt: Date | null
+ createdAt: Date
+ updatedAt: Date
+}
+
+interface VendorQuotationComparisonDialogProps {
+ open: boolean
+ onOpenChange: (open: boolean) => void
+ selectedRfq: {
+ id: number;
+ rfqCode: string | null;
+ status: string;
+ [key: string]: any; // eslint-disable-line @typescript-eslint/no-explicit-any
+ } | null
+}
+
+export function VendorQuotationComparisonDialog({
+ open,
+ onOpenChange,
+ selectedRfq,
+}: VendorQuotationComparisonDialogProps) {
+ const [isLoading, setIsLoading] = useState(false)
+ const [quotations, setQuotations] = useState<TechSalesVendorQuotation[]>([])
+ const [selectedVendorId, setSelectedVendorId] = useState<number | null>(null)
+ const [isAccepting, setIsAccepting] = useState(false)
+ const [showConfirmDialog, setShowConfirmDialog] = useState(false)
+
+ useEffect(() => {
+ async function loadQuotationData() {
+ if (!open || !selectedRfq?.id) return
+
+ try {
+ setIsLoading(true)
+ // 기술영업 견적 목록 조회 (제출된 견적만)
+ const result = await getTechSalesVendorQuotationsWithJoin({
+ rfqId: selectedRfq.id,
+ page: 1,
+ perPage: 100,
+ filters: [
+ {
+ id: "status" as keyof typeof techSalesVendorQuotations,
+ value: "Submitted",
+ type: "select" as const,
+ operator: "eq" as const,
+ rowId: "status"
+ }
+ ]
+ })
+
+ setQuotations(result.data || [])
+ } 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"
+ }
+ }
+
+ // 벤더 선택 핸들러
+ const handleSelectVendor = (vendorId: number) => {
+ setSelectedVendorId(vendorId)
+ setShowConfirmDialog(true)
+ }
+
+ // 벤더 선택 확정
+ const handleConfirmSelection = async () => {
+ if (!selectedVendorId) return
+
+ try {
+ setIsAccepting(true)
+
+ // 선택된 견적의 ID 찾기
+ const selectedQuotation = quotations.find(q => q.vendorId === selectedVendorId)
+ if (!selectedQuotation) {
+ toast.error("선택된 견적을 찾을 수 없습니다")
+ return
+ }
+
+ // 벤더 선택 API 호출
+ const result = await acceptTechSalesVendorQuotationAction(selectedQuotation.id)
+
+ if (result.success) {
+ toast.success(result.message || "벤더가 선택되었습니다")
+ setShowConfirmDialog(false)
+ onOpenChange(false)
+
+ // 페이지 새로고침 또는 데이터 재로드
+ window.location.reload()
+ } else {
+ toast.error(result.error || "벤더 선택에 실패했습니다")
+ }
+ } catch (error) {
+ console.error("벤더 선택 오류:", error)
+ toast.error("벤더 선택에 실패했습니다")
+ } finally {
+ setIsAccepting(false)
+ }
+ }
+
+ const selectedVendor = quotations.find(q => q.vendorId === selectedVendorId)
+
+ return (
+ <>
+ <Dialog open={open} onOpenChange={onOpenChange}>
+ <DialogContent className="max-w-[90vw] lg:max-w-5xl max-h-[90vh]">
+ <DialogHeader>
+ <DialogTitle>벤더 견적 비교 및 선택</DialogTitle>
+ <DialogDescription>
+ {selectedRfq
+ ? `RFQ ${selectedRfq.rfqCode} - 제출된 견적을 비교하고 벤더를 선택하세요`
+ : ""}
+ </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>
+ ) : (
+ <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 w-32">
+ 항목
+ </TableHead>
+ {quotations.map((q) => (
+ <TableHead key={q.id} className="p-2 text-center whitespace-nowrap w-48">
+ <div className="flex flex-col items-center gap-2">
+ <span>{q.vendorName || `벤더 ID: ${q.vendorId}`}</span>
+ <Button
+ size="sm"
+ variant={q.status === "Accepted" ? "default" : "outline"}
+ onClick={() => handleSelectVendor(q.vendorId)}
+ disabled={q.status === "Accepted"}
+ className="gap-1"
+ >
+ {q.status === "Accepted" ? (
+ <>
+ <CheckCircle className="h-4 w-4" />
+ 선택됨
+ </>
+ ) : (
+ "선택"
+ )}
+ </Button>
+ </div>
+ </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 text-center">
+ <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={`total-${q.id}`} className="p-2 font-semibold text-center">
+ {q.totalPrice ? formatCurrency(Number(q.totalPrice), q.currency || 'USD') : '-'}
+ </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 text-center">
+ {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 text-center">
+ {q.validUntil ? 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={`submitted-${q.id}`} className="p-2 text-center">
+ {q.submittedAt ? 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 text-center"
+ >
+ {q.remark || "-"}
+ </TableCell>
+ ))}
+ </TableRow>
+ </tbody>
+ </table>
+ </div>
+ )}
+
+ <DialogFooter>
+ <Button variant="outline" onClick={() => onOpenChange(false)}>
+ 닫기
+ </Button>
+ </DialogFooter>
+ </DialogContent>
+ </Dialog>
+
+ {/* 벤더 선택 확인 다이얼로그 */}
+ <AlertDialog open={showConfirmDialog} onOpenChange={setShowConfirmDialog}>
+ <AlertDialogContent>
+ <AlertDialogHeader>
+ <AlertDialogTitle>벤더 선택 확인</AlertDialogTitle>
+ <AlertDialogDescription>
+ <strong>{selectedVendor?.vendorName || `벤더 ID: ${selectedVendorId}`}</strong>를 선택하시겠습니까?
+ <br />
+ <br />
+ 선택된 벤더의 견적이 승인되며, 다른 벤더들의 견적은 자동으로 거절됩니다.
+ 이 작업은 되돌릴 수 없습니다.
+ </AlertDialogDescription>
+ </AlertDialogHeader>
+ <AlertDialogFooter>
+ <AlertDialogCancel disabled={isAccepting}>취소</AlertDialogCancel>
+ <AlertDialogAction
+ onClick={handleConfirmSelection}
+ disabled={isAccepting}
+ className="gap-2"
+ >
+ {isAccepting && <Loader2 className="h-4 w-4 animate-spin" />}
+ 확인
+ </AlertDialogAction>
+ </AlertDialogFooter>
+ </AlertDialogContent>
+ </AlertDialog>
+ </>
+ )
+}