From 9eb8e80f4f736c4edffa650c685d1f170ca51aa1 Mon Sep 17 00:00:00 2001 From: dujinkim Date: Thu, 15 May 2025 01:19:49 +0000 Subject: (대표님) 구매 요청사항 반영한 통합 rfq / 필터 개인화 / po-rfq MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../po-rfq/detail-table/add-vendor-dialog.tsx | 512 ++++++++++++++++ .../po-rfq/detail-table/delete-vendor-dialog.tsx | 150 +++++ .../po-rfq/detail-table/rfq-detail-column.tsx | 369 ++++++++++++ .../po-rfq/detail-table/rfq-detail-table.tsx | 519 ++++++++++++++++ .../po-rfq/detail-table/update-vendor-sheet.tsx | 449 ++++++++++++++ .../detail-table/vendor-communication-drawer.tsx | 518 ++++++++++++++++ .../vendor-quotation-comparison-dialog.tsx | 665 +++++++++++++++++++++ components/po-rfq/po-rfq-container.tsx | 261 ++++++++ components/po-rfq/rfq-filter-sheet.tsx | 643 ++++++++++++++++++++ 9 files changed, 4086 insertions(+) create mode 100644 components/po-rfq/detail-table/add-vendor-dialog.tsx create mode 100644 components/po-rfq/detail-table/delete-vendor-dialog.tsx create mode 100644 components/po-rfq/detail-table/rfq-detail-column.tsx create mode 100644 components/po-rfq/detail-table/rfq-detail-table.tsx create mode 100644 components/po-rfq/detail-table/update-vendor-sheet.tsx create mode 100644 components/po-rfq/detail-table/vendor-communication-drawer.tsx create mode 100644 components/po-rfq/detail-table/vendor-quotation-comparison-dialog.tsx create mode 100644 components/po-rfq/po-rfq-container.tsx create mode 100644 components/po-rfq/rfq-filter-sheet.tsx (limited to 'components/po-rfq') diff --git a/components/po-rfq/detail-table/add-vendor-dialog.tsx b/components/po-rfq/detail-table/add-vendor-dialog.tsx new file mode 100644 index 00000000..5e83ad8f --- /dev/null +++ b/components/po-rfq/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 }) => ( + + {children} * + +); + +// 폼 유효성 검증 스키마 +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 + +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([]) + const [isSubmitting, setIsSubmitting] = useState(false) + + // 벤더 선택을 위한 팝오버 상태 + const [vendorOpen, setVendorOpen] = useState(false) + + const form = useForm({ + 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) => { + 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 ( + + {/* 커스텀 DialogContent - 고정 헤더, 스크롤 가능한 콘텐츠, 고정 푸터 */} + + {/* 고정 헤더 */} +
+ + 벤더 추가 + + {selectedRfq ? ( + <> + {selectedRfq.rfqCode} RFQ에 벤더를 추가합니다. + + ) : ( + "RFQ에 벤더를 추가합니다." + )} + + +
+ + {/* 스크롤 가능한 콘텐츠 영역 */} +
+
+ + {/* 검색 가능한 벤더 선택 필드 */} + ( + + 벤더 + + + + + + + + + + 검색 결과가 없습니다 + + + + {availableVendors.length > 0 ? ( + availableVendors.map((vendor) => ( + { + form.setValue("vendorId", String(vendor.id), { + shouldValidate: true, + }) + setVendorOpen(false) + }} + > + + {vendor.vendorName} ({vendor.vendorCode}) + + )) + ) : ( + 추가 가능한 벤더가 없습니다 + )} + + + + + + + + + )} + /> + + ( + + 통화 + + + + )} + /> + +
+ ( + + 지불 조건 + + + + )} + /> + + ( + + 인코텀즈 + + + + )} + /> +
+ + {/* 나머지 필드들은 동일하게 유지 */} + ( + + 인코텀즈 세부사항 + + + + + + )} + /> + +
+ ( + + 납품 예정일 + + + + + + )} + /> + + ( + + 세금 코드 + + + + + + )} + /> +
+ +
+ ( + + 선적지 + + + + + + )} + /> + + ( + + 도착지 + + + + + + )} + /> +
+ + ( + + + + +
+ 자재 가격 관련 여부 +
+
+ )} + /> + + {/* 파일 업로드 섹션 */} +
+ +
+
+ +
+ + {/* 업로드된 파일 목록 */} + {attachments.length > 0 && ( +
+

업로드된 파일

+
    + {attachments.map((file, index) => ( +
  • +
    + + {file.name} + + ({(file.size / 1024).toFixed(1)} KB) + +
    + +
  • + ))} +
+
+ )} +
+
+ + +
+ + {/* 고정 푸터 */} +
+ + + + +
+
+
+ ) +} \ No newline at end of file diff --git a/components/po-rfq/detail-table/delete-vendor-dialog.tsx b/components/po-rfq/detail-table/delete-vendor-dialog.tsx new file mode 100644 index 00000000..49d982e1 --- /dev/null +++ b/components/po-rfq/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 { + 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 ( + + {showTrigger ? ( + + + + ) : null} + + + 정말로 삭제하시겠습니까? + + 이 작업은 되돌릴 수 없습니다. 벤더 "{detail?.vendorName}"({detail?.vendorCode})의 RFQ 정보가 영구적으로 삭제됩니다. + + + + + + + + + + + ) + } + + return ( + + {showTrigger ? ( + + + + ) : null} + + + 정말로 삭제하시겠습니까? + + 이 작업은 되돌릴 수 없습니다. 벤더 "{detail?.vendorName}"({detail?.vendorCode})의 RFQ 정보가 영구적으로 삭제됩니다. + + + + + + + + + + + ) +} \ No newline at end of file diff --git a/components/po-rfq/detail-table/rfq-detail-column.tsx b/components/po-rfq/detail-table/rfq-detail-column.tsx new file mode 100644 index 00000000..31f251ce --- /dev/null +++ b/components/po-rfq/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 { + row: Row; + 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 { + setRowAction: React.Dispatch< + React.SetStateAction | null> + >; + unreadMessages?: Record; // 벤더 ID별 읽지 않은 메시지 수 +} + +export function getRfqDetailColumns({ + setRowAction, + unreadMessages = {}, +}: GetColumnsProps): ColumnDef[] { + return [ + { + accessorKey: "quotationStatus", + header: ({ column }) => ( + + ), + cell: ({ row }) =>
{row.getValue("quotationStatus")}
, + meta: { + excelHeader: "견적 상태" + }, + enableResizing: true, + size: 120, + }, + { + accessorKey: "quotationVersion", + header: ({ column }) => ( + + ), + cell: ({ row }) =>
{row.getValue("quotationVersion")}
, + meta: { + excelHeader: "견적 버전" + }, + enableResizing: true, + size: 120, + }, + { + accessorKey: "vendorCode", + header: ({ column }) => ( + + ), + cell: ({ row }) =>
{row.getValue("vendorCode")}
, + meta: { + excelHeader: "벤더 코드" + }, + enableResizing: true, + size: 120, + }, + { + accessorKey: "vendorName", + header: ({ column }) => ( + + ), + cell: ({ row }) =>
{row.getValue("vendorName")}
, + meta: { + excelHeader: "벤더명" + }, + enableResizing: true, + size: 160, + }, + { + accessorKey: "vendorType", + header: ({ column }) => ( + + ), + cell: ({ row }) =>
{row.original.vendorCountry === "KR"?"D":"F"}
, + meta: { + excelHeader: "내외자" + }, + enableResizing: true, + size: 80, + }, + { + accessorKey: "currency", + header: ({ column }) => ( + + ), + cell: ({ row }) =>
{row.getValue("currency")}
, + meta: { + excelHeader: "통화" + }, + enableResizing: true, + size: 80, + }, + { + accessorKey: "paymentTermsCode", + header: ({ column }) => ( + + ), + cell: ({ row }) =>
{row.getValue("paymentTermsCode")}
, + meta: { + excelHeader: "지불 조건 코드" + }, + enableResizing: true, + size: 140, + }, + { + accessorKey: "paymentTermsDescription", + header: ({ column }) => ( + + ), + cell: ({ row }) =>
{row.getValue("paymentTermsDescription")}
, + meta: { + excelHeader: "지불 조건" + }, + enableResizing: true, + size: 160, + }, + { + accessorKey: "incotermsCode", + header: ({ column }) => ( + + ), + cell: ({ row }) =>
{row.getValue("incotermsCode")}
, + meta: { + excelHeader: "인코텀스 코드" + }, + enableResizing: true, + size: 140, + }, + { + accessorKey: "incotermsDescription", + header: ({ column }) => ( + + ), + cell: ({ row }) =>
{row.getValue("incotermsDescription")}
, + meta: { + excelHeader: "인코텀스" + }, + enableResizing: true, + size: 160, + }, + { + accessorKey: "incotermsDetail", + header: ({ column }) => ( + + ), + cell: ({ row }) =>
{row.getValue("incotermsDetail")}
, + meta: { + excelHeader: "인코텀스 상세" + }, + enableResizing: true, + size: 160, + }, + { + accessorKey: "deliveryDate", + header: ({ column }) => ( + + ), + cell: ({ cell }) => { + const value = cell.getValue(); + return value ? formatDate(value as Date, "KR") : ""; + }, + meta: { + excelHeader: "납품일" + }, + enableResizing: true, + size: 120, + }, + { + accessorKey: "taxCode", + header: ({ column }) => ( + + ), + cell: ({ row }) =>
{row.getValue("taxCode")}
, + meta: { + excelHeader: "세금 코드" + }, + enableResizing: true, + size: 120, + }, + { + accessorKey: "placeOfShipping", + header: ({ column }) => ( + + ), + cell: ({ row }) =>
{row.getValue("placeOfShipping")}
, + meta: { + excelHeader: "선적지" + }, + enableResizing: true, + size: 140, + }, + { + accessorKey: "placeOfDestination", + header: ({ column }) => ( + + ), + cell: ({ row }) =>
{row.getValue("placeOfDestination")}
, + meta: { + excelHeader: "도착지" + }, + enableResizing: true, + size: 140, + }, + { + accessorKey: "materialPriceRelatedYn", + header: ({ column }) => ( + + ), + cell: ({ row }) =>
{row.getValue("materialPriceRelatedYn") ? "Y" : "N"}
, + meta: { + excelHeader: "원자재 가격 연동" + }, + enableResizing: true, + size: 140, + }, + { + accessorKey: "updatedByUserName", + header: ({ column }) => ( + + ), + cell: ({ row }) =>
{row.getValue("updatedByUserName")}
, + meta: { + excelHeader: "수정자" + }, + enableResizing: true, + size: 120, + }, + { + accessorKey: "updatedAt", + header: ({ column }) => ( + + ), + cell: ({ cell }) => { + const value = cell.getValue(); + return value ? formatDateTime(value as Date, "KR") : ""; + }, + meta: { + excelHeader: "수정일시" + }, + enableResizing: true, + size: 140, + }, + // 커뮤니케이션 컬럼 추가 + { + id: "communication", + header: ({ column }) => ( + + ), + cell: ({ row }) => { + const vendorId = row.original.vendorId || 0; + const unreadCount = unreadMessages[vendorId] || 0; + + return ( + + ); + }, + enableResizing: false, + size: 80, + }, + { + id: "actions", + enableHiding: false, + cell: function Cell({ row }) { + const [isUpdatePending, startUpdateTransition] = React.useTransition() + + return ( + + + + + + setRowAction({ row, type: "update" })} + > + Edit + + + setRowAction({ row, type: "delete" })} + > + Delete + ⌘⌫ + + + + ) + }, + size: 40, + } + ] +} \ No newline at end of file diff --git a/components/po-rfq/detail-table/rfq-detail-table.tsx b/components/po-rfq/detail-table/rfq-detail-table.tsx new file mode 100644 index 00000000..787b7c3b --- /dev/null +++ b/components/po-rfq/detail-table/rfq-detail-table.tsx @@ -0,0 +1,519 @@ +"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 +} + +// 데이터 타입 정의 +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 }: RfqDetailTablesProps) { + + console.log("selectedRfq", selectedRfq) + // 상태 관리 + const [isLoading, setIsLoading] = useState(false) + const [isRefreshing, setIsRefreshing] = useState(false) + const [details, setDetails] = useState([]) + const [vendorDialogOpen, setVendorDialogOpen] = React.useState(false) + const [updateSheetOpen, setUpdateSheetOpen] = React.useState(false) + const [deleteDialogOpen, setDeleteDialogOpen] = React.useState(false) + const [selectedDetail, setSelectedDetail] = React.useState(null) + + const [vendors, setVendors] = React.useState([]) + const [currencies, setCurrencies] = React.useState([]) + const [paymentTerms, setPaymentTerms] = React.useState([]) + const [incoterms, setIncoterms] = React.useState([]) + const [isAdddialogLoading, setIsAdddialogLoading] = useState(false) + + const [rowAction, setRowAction] = React.useState | null>(null) + + // 벤더 커뮤니케이션 상태 관리 + const [communicationDrawerOpen, setCommunicationDrawerOpen] = useState(false) + const [selectedVendor, setSelectedVendor] = useState(null) + + // 읽지 않은 메시지 개수 + const [unreadMessages, setUnreadMessages] = useState>({}) + 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 ( +
+ RFQ를 선택하세요 +
+ ) + } + + // 로딩 중인 경우 + if (isLoading) { + return ( +
+ + + +
+ ) + } + + 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 ( +
+ + {/* 메시지 및 새로고침 영역 */} + + + {/* 테이블 또는 빈 상태 표시 */} + {details.length > 0 ? ( + + + +
+
+ {totalUnreadMessages > 0 && ( + + 읽지 않은 메시지: {totalUnreadMessages}건 + + )} + {vendorsWithQuotations > 0 && ( + + 견적 제출: {vendorsWithQuotations}개 벤더 + + )} +
+
+ {/* 견적 비교 버튼 추가 */} + + +
+
+ +
+ + ) : ( +
+
+

RFQ에 대한 세부 정보가 없습니다

+ +
+
+ )} + + {/* 벤더 추가 다이얼로그 */} + { + setVendorDialogOpen(open); + if (!open) setIsAdddialogLoading(false); + }} + selectedRfq={selectedRfq} + vendors={vendors} + currencies={currencies} + paymentTerms={paymentTerms} + incoterms={incoterms} + onSuccess={handleRefreshData} + existingVendorIds={existingVendorIds} + /> + + {/* 벤더 정보 수정 시트 */} + + + {/* 벤더 정보 삭제 다이얼로그 */} + + + {/* 벤더 커뮤니케이션 드로어 */} + { + setCommunicationDrawerOpen(open); + // 드로어가 닫힐 때 읽지 않은 메시지 개수 갱신 + if (!open) loadUnreadMessages(); + }} + selectedRfq={selectedRfq} + selectedVendor={selectedVendor} + onSuccess={handleRefreshData} + /> + + {/* 견적 비교 다이얼로그 추가 */} + +
+ ) +} \ No newline at end of file diff --git a/components/po-rfq/detail-table/update-vendor-sheet.tsx b/components/po-rfq/detail-table/update-vendor-sheet.tsx new file mode 100644 index 00000000..45e4a602 --- /dev/null +++ b/components/po-rfq/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 + +// 데이터 타입 정의 +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 { + 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({ + 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 ( + + + + RFQ 벤더 정보 수정 + + 벤더 정보를 수정하고 저장하세요 + + + +
+ + {/* 검색 가능한 벤더 선택 필드 */} + ( + + 벤더 * + + + + + + + + + + 검색 결과가 없습니다 + + + {vendors.map((vendor) => ( + { + form.setValue("vendorId", String(vendor.id), { + shouldValidate: true, + }) + setVendorOpen(false) + }} + > + + {vendor.vendorName} ({vendor.vendorCode}) + + ))} + + + + + + + + )} + /> + + ( + + 통화 * + + + + )} + /> + +
+ ( + + 지불 조건 * + + + + )} + /> + + ( + + 인코텀즈 * + + + + )} + /> +
+ + ( + + 인코텀즈 세부사항 + + + + + + )} + /> + +
+ ( + + 납품 예정일 + + + + + + )} + /> + + ( + + 세금 코드 + + + + + + )} + /> +
+ +
+ ( + + 선적지 + + + + + + )} + /> + + ( + + 도착지 + + + + + + )} + /> +
+ + ( + + + + +
+ 자재 가격 관련 여부 +
+
+ )} + /> + + +
+ + + + + + +
+
+ ) +} \ No newline at end of file diff --git a/components/po-rfq/detail-table/vendor-communication-drawer.tsx b/components/po-rfq/detail-table/vendor-communication-drawer.tsx new file mode 100644 index 00000000..34efdfc2 --- /dev/null +++ b/components/po-rfq/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 { + 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([]); + const [newComment, setNewComment] = useState(""); + const [attachments, setAttachments] = useState([]); + const [isLoading, setIsLoading] = useState(false); + const [isSubmitting, setIsSubmitting] = useState(false); + const fileInputRef = useRef(null); + const messagesEndRef = useRef(null); + + // 첨부파일 관련 상태 + const [previewDialogOpen, setPreviewDialogOpen] = useState(false); + const [selectedAttachment, setSelectedAttachment] = useState(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) => { + 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 ; + if (fileType.includes("pdf")) return ; + if (fileType.includes("spreadsheet") || fileType.includes("excel")) + return ; + if (fileType.includes("document") || fileType.includes("word")) + return ; + return ; + }; + + // 첨부파일 미리보기 다이얼로그 + const renderAttachmentPreviewDialog = () => { + if (!selectedAttachment) return null; + + const isImage = selectedAttachment.fileType.startsWith("image/"); + const isPdf = selectedAttachment.fileType.includes("pdf"); + + return ( + + + + + {getFileIcon(selectedAttachment.fileType)} + {selectedAttachment.fileName} + + + {formatFileSize(selectedAttachment.fileSize)} • {formatDateTime(selectedAttachment.uploadedAt)} + + + +
+ {isImage ? ( + {selectedAttachment.fileName} + ) : isPdf ? ( +