From 3bdd648ad4cb863043db181291ddaebbc025965b Mon Sep 17 00:00:00 2001 From: dujinkim Date: Thu, 29 May 2025 10:25:22 +0000 Subject: (김준회) 기술영업 조선 RFQ 변경 (셀 클릭 상세보기 및 Unread Message, Action Column 관련 변경사항 적용) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/techsales-rfq/service.ts | 40 ++++++ lib/techsales-rfq/table/delete-vendors-dialog.tsx | 119 ++++++++++++++++ .../table/detail-table/delete-vendor-dialog.tsx | 150 --------------------- .../table/detail-table/delete-vendors-dialog.tsx | 150 +++++++++++++++++++++ .../table/detail-table/rfq-detail-column.tsx | 70 +++++----- .../table/detail-table/rfq-detail-table.tsx | 128 ++++++++++++------ lib/techsales-rfq/table/rfq-table-column.tsx | 54 ++------ lib/techsales-rfq/table/rfq-table.tsx | 5 - 8 files changed, 452 insertions(+), 264 deletions(-) create mode 100644 lib/techsales-rfq/table/delete-vendors-dialog.tsx delete mode 100644 lib/techsales-rfq/table/detail-table/delete-vendor-dialog.tsx create mode 100644 lib/techsales-rfq/table/detail-table/delete-vendors-dialog.tsx (limited to 'lib') diff --git a/lib/techsales-rfq/service.ts b/lib/techsales-rfq/service.ts index f7a30b3b..f3bb2e59 100644 --- a/lib/techsales-rfq/service.ts +++ b/lib/techsales-rfq/service.ts @@ -2489,6 +2489,46 @@ export interface TechSalesComment { isRead: boolean | null // null 허용으로 변경 } +/** + * 특정 RFQ의 벤더별 읽지 않은 메시지 개수를 조회하는 함수 + * + * @param rfqId RFQ ID + * @returns 벤더별 읽지 않은 메시지 개수 (vendorId: count) + */ +export async function getTechSalesUnreadMessageCounts(rfqId: number): Promise> { + try { + // 벤더가 보낸 읽지 않은 메시지를 벤더별로 카운트 + const unreadCounts = await db + .select({ + vendorId: techSalesRfqComments.vendorId, + count: sql`count(*)`, + }) + .from(techSalesRfqComments) + .where( + and( + eq(techSalesRfqComments.rfqId, rfqId), + eq(techSalesRfqComments.isVendorComment, true), // 벤더가 보낸 메시지 + eq(techSalesRfqComments.isRead, false), // 읽지 않은 메시지 + sql`${techSalesRfqComments.vendorId} IS NOT NULL` // vendorId가 null이 아닌 것 + ) + ) + .groupBy(techSalesRfqComments.vendorId); + + // Record 형태로 변환 + const result: Record = {}; + unreadCounts.forEach(item => { + if (item.vendorId) { + result[item.vendorId] = item.count; + } + }); + + return result; + } catch (error) { + console.error('techSales 읽지 않은 메시지 개수 조회 오류:', error); + return {}; + } +} + /** * 특정 RFQ와 벤더 간의 커뮤니케이션 메시지를 가져오는 서버 액션 * diff --git a/lib/techsales-rfq/table/delete-vendors-dialog.tsx b/lib/techsales-rfq/table/delete-vendors-dialog.tsx new file mode 100644 index 00000000..35c3b067 --- /dev/null +++ b/lib/techsales-rfq/table/delete-vendors-dialog.tsx @@ -0,0 +1,119 @@ +"use client" + +import * as React from "react" +import { type RfqDetailView } from "./rfq-detail-column" +import { Loader, Trash } from "lucide-react" + +import { useMediaQuery } from "@/hooks/use-media-query" +import { Button } from "@/components/ui/button" +import { + Dialog, + DialogClose, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog" +import { + Drawer, + DrawerClose, + DrawerContent, + DrawerDescription, + DrawerFooter, + DrawerHeader, + DrawerTitle, +} from "@/components/ui/drawer" + +interface DeleteVendorsDialogProps + extends React.ComponentPropsWithoutRef { + vendors: RfqDetailView[] + onConfirm: () => void + isLoading?: boolean +} + +export function DeleteVendorsDialog({ + vendors, + onConfirm, + isLoading = false, + ...props +}: DeleteVendorsDialogProps) { + const isDesktop = useMediaQuery("(min-width: 640px)") + + const vendorNames = vendors.map(v => v.vendorName).filter(Boolean).join(", ") + + if (isDesktop) { + return ( + + + + 벤더 삭제 확인 + + 정말로 선택한 {vendors.length}개의 벤더를 삭제하시겠습니까? +
+
+ 삭제될 벤더: {vendorNames} +
+
+ 이 작업은 되돌릴 수 없습니다. +
+
+ + + + + + +
+
+ ) + } + + return ( + + + + 벤더 삭제 확인 + + 정말로 선택한 {vendors.length}개의 벤더를 삭제하시겠습니까? +
+
+ 삭제될 벤더: {vendorNames} +
+
+ 이 작업은 되돌릴 수 없습니다. +
+
+ + + + + + +
+
+ ) +} \ 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 deleted file mode 100644 index d7e3403b..00000000 --- a/lib/techsales-rfq/table/detail-table/delete-vendor-dialog.tsx +++ /dev/null @@ -1,150 +0,0 @@ -"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 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 ( - - {showTrigger ? ( - - - - ) : null} - - - 정말로 삭제하시겠습니까? - - 이 작업은 되돌릴 수 없습니다. 벤더 "{detail?.vendorName}"({detail?.vendorCode})의 RFQ 정보가 영구적으로 삭제됩니다. - - - - - - - - - - - ) - } - - return ( - - {showTrigger ? ( - - - - ) : null} - - - 정말로 삭제하시겠습니까? - - 이 작업은 되돌릴 수 없습니다. 벤더 "{detail?.vendorName}"({detail?.vendorCode})의 RFQ 정보가 영구적으로 삭제됩니다. - - - - - - - - - - - ) -} \ No newline at end of file diff --git a/lib/techsales-rfq/table/detail-table/delete-vendors-dialog.tsx b/lib/techsales-rfq/table/detail-table/delete-vendors-dialog.tsx new file mode 100644 index 00000000..d7e3403b --- /dev/null +++ b/lib/techsales-rfq/table/detail-table/delete-vendors-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 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 ( + + {showTrigger ? ( + + + + ) : null} + + + 정말로 삭제하시겠습니까? + + 이 작업은 되돌릴 수 없습니다. 벤더 "{detail?.vendorName}"({detail?.vendorCode})의 RFQ 정보가 영구적으로 삭제됩니다. + + + + + + + + + + + ) + } + + return ( + + {showTrigger ? ( + + + + ) : null} + + + 정말로 삭제하시겠습니까? + + 이 작업은 되돌릴 수 없습니다. 벤더 "{detail?.vendorName}"({detail?.vendorCode})의 RFQ 정보가 영구적으로 삭제됩니다. + + + + + + + + + + + ) +} \ 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 index cfae0bd7..7d5c359e 100644 --- a/lib/techsales-rfq/table/detail-table/rfq-detail-column.tsx +++ b/lib/techsales-rfq/table/detail-table/rfq-detail-column.tsx @@ -4,21 +4,20 @@ 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 { Checkbox } from "@/components/ui/checkbox"; +import { MessageCircle, MoreHorizontal, Trash2 } from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { Badge } from "@/components/ui/badge"; 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"; -import { useRouter } from "next/navigation"; export interface DataTableRowAction { row: Row; - type: "delete" | "communicate"; + type: "communicate" | "delete"; } // 벤더 견적 데이터 타입 정의 @@ -128,7 +127,28 @@ export function getRfqDetailColumns({ header: ({ column }) => ( ), - cell: ({ row }) =>
{row.getValue("vendorName")}
, + cell: ({ row }) => { + const vendorName = row.getValue("vendorName") as string | null; + const vendorId = row.original.vendorId; + + if (!vendorName) return
-
; + + if (vendorId) { + return ( + + ); + } + + return
{vendorName}
; + }, meta: { excelHeader: "벤더명" }, @@ -233,13 +253,8 @@ export function getRfqDetailColumns({ cell: function Cell({ row }) { const vendorId = row.original.vendorId; const unreadCount = vendorId ? unreadMessages[vendorId] || 0 : 0; - const router = useRouter(); - - const handleViewDetails = () => { - if (vendorId) { - router.push(`/ko/evcp/vendors/${vendorId}/info`); - } - }; + const status = row.original.status; + const isDraft = status === "Draft"; return (
@@ -264,31 +279,26 @@ export function getRfqDetailColumns({ )}
- {/* 기존 드롭다운 메뉴 */} + {/* 컨텍스트 메뉴 */} - - - {/* */} - 벤더 상세정보 - + setRowAction({ row, type: "delete" })} - className="text-destructive focus:text-destructive" + disabled={!isDraft} + className={!isDraft ? "opacity-50 cursor-not-allowed" : "text-destructive focus:text-destructive"} > - 벤더 제거 + + 벤더 삭제 @@ -296,7 +306,7 @@ export function getRfqDetailColumns({ ); }, enableResizing: false, - size: 80, + size: 120, }, ]; } \ 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 index a2f012ad..dbaeae0c 100644 --- a/lib/techsales-rfq/table/detail-table/rfq-detail-table.tsx +++ b/lib/techsales-rfq/table/detail-table/rfq-detail-table.tsx @@ -15,7 +15,7 @@ import { Button } from "@/components/ui/button" import { Loader2, UserPlus, BarChart2, Send, Trash2 } 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 { DeleteVendorsDialog } from "./delete-vendors-dialog" import { VendorCommunicationDrawer } from "./vendor-communication-drawer" import { VendorQuotationComparisonDialog } from "./vendor-quotation-comparison-dialog" @@ -48,8 +48,6 @@ export function RfqDetailTables({ selectedRfq, maxHeight }: RfqDetailTablesProps const [isLoading, setIsLoading] = useState(false) const [details, setDetails] = useState([]) const [vendorDialogOpen, setVendorDialogOpen] = React.useState(false) - const [deleteDialogOpen, setDeleteDialogOpen] = React.useState(false) - const [selectedDetail, setSelectedDetail] = React.useState(null) const [isAdddialogLoading, setIsAdddialogLoading] = useState(false) @@ -70,6 +68,9 @@ export function RfqDetailTables({ selectedRfq, maxHeight }: RfqDetailTablesProps const [isSendingRfq, setIsSendingRfq] = useState(false) const [isDeletingVendors, setIsDeletingVendors] = useState(false) + // 벤더 삭제 확인 다이얼로그 상태 추가 + const [deleteConfirmDialogOpen, setDeleteConfirmDialogOpen] = useState(false) + // selectedRfq ID 메모이제이션 (객체 참조 변경 방지) const selectedRfqId = useMemo(() => selectedRfq?.id, [selectedRfq?.id]) @@ -83,12 +84,13 @@ export function RfqDetailTables({ selectedRfq, maxHeight }: RfqDetailTablesProps if (!selectedRfqId) return; try { - // TODO: 기술영업용 읽지 않은 메시지 수 가져오기 함수 구현 필요 - // const unreadData = await fetchUnreadMessages(selectedRfqId); - // setUnreadMessages(unreadData); - setUnreadMessages({}); + // 기술영업용 읽지 않은 메시지 수 가져오기 함수 구현 + const { getTechSalesUnreadMessageCounts } = await import("@/lib/techsales-rfq/service"); + const unreadData = await getTechSalesUnreadMessageCounts(selectedRfqId); + setUnreadMessages(unreadData); } catch (error) { console.error("읽지 않은 메시지 로드 오류:", error); + setUnreadMessages({}); } }, [selectedRfqId]); @@ -236,6 +238,21 @@ export function RfqDetailTables({ selectedRfq, maxHeight }: RfqDetailTablesProps } }, [selectedRows, selectedRfqId, handleRefreshData]); + // 벤더 삭제 확인 핸들러 + const handleDeleteVendorsConfirm = useCallback(() => { + if (selectedRows.length === 0) { + toast.warning("삭제할 벤더를 선택해주세요."); + return; + } + setDeleteConfirmDialogOpen(true); + }, [selectedRows]); + + // 벤더 삭제 확정 실행 + const executeDeleteVendors = useCallback(async () => { + setDeleteConfirmDialogOpen(false); + await handleDeleteVendors(); + }, [handleDeleteVendors]); + // 견적 비교 다이얼로그 열기 핸들러 메모이제이션 const handleOpenComparisonDialog = useCallback(() => { // 제출된 견적이 있는 벤더가 최소 1개 이상 있는지 확인 @@ -281,11 +298,6 @@ export function RfqDetailTables({ selectedRfq, maxHeight }: RfqDetailTablesProps ) // 계산된 값들 메모이제이션 - const totalUnreadMessages = useMemo(() => - Object.values(unreadMessages).reduce((sum, count) => sum + count, 0), - [unreadMessages] - ); - const vendorsWithQuotations = useMemo(() => details.filter(detail => detail.status === "Submitted").length, [details] @@ -360,24 +372,45 @@ export function RfqDetailTables({ selectedRfq, maxHeight }: RfqDetailTablesProps setSelectedVendor(rowAction.row.original); setCommunicationDrawerOpen(true); - // 해당 벤더의 읽지 않은 메시지를 0으로 설정 (메시지를 읽은 것으로 간주) - const vendorId = rowAction.row.original.vendorId; - if (vendorId) { - setUnreadMessages(prev => ({ - ...prev, - [vendorId]: 0 - })); - } - // rowAction 초기화 setRowAction(null); return; } - // 삭제 액션인 경우 + // 삭제 액션인 경우 개별 벤더 삭제 if (rowAction.type === "delete") { - setSelectedDetail(rowAction.row.original); - setDeleteDialogOpen(true); + const vendor = rowAction.row.original; + + if (!vendor.vendorId || !selectedRfqId) { + toast.error("벤더 정보가 없습니다."); + setRowAction(null); + return; + } + + // Draft 상태 체크 + if (vendor.status !== "Draft") { + toast.error("Draft 상태의 벤더만 삭제할 수 있습니다."); + setRowAction(null); + return; + } + + // 개별 벤더 삭제 + const { removeVendorFromTechSalesRfq } = await import("@/lib/techsales-rfq/service"); + + const result = await removeVendorFromTechSalesRfq({ + rfqId: selectedRfqId, + vendorId: vendor.vendorId + }); + + if (result.error) { + toast.error(result.error); + } else { + toast.success(`${vendor.vendorName || '벤더'}가 성공적으로 삭제되었습니다.`); + // 데이터 새로고침 + await handleRefreshData(); + } + + // rowAction 초기화 setRowAction(null); return; } @@ -388,7 +421,7 @@ export function RfqDetailTables({ selectedRfq, maxHeight }: RfqDetailTablesProps }; handleRowAction(); - }, [rowAction]) + }, [rowAction, selectedRfqId, handleRefreshData]) // 선택된 행 변경 핸들러 메모이제이션 const handleSelectedRowsChange = useCallback((selectedRowsData: RfqDetailView[]) => { @@ -398,9 +431,25 @@ export function RfqDetailTables({ selectedRfq, maxHeight }: RfqDetailTablesProps // 커뮤니케이션 드로어 변경 핸들러 메모이제이션 const handleCommunicationDrawerChange = useCallback((open: boolean) => { setCommunicationDrawerOpen(open); - // 드로어가 닫힐 때 읽지 않은 메시지 개수 갱신 - if (!open) loadUnreadMessages(); - }, [loadUnreadMessages]); + // 드로어가 닫힐 때 해당 벤더의 메시지를 읽음 처리하고 읽지 않은 메시지 개수 갱신 + if (!open && selectedVendor?.vendorId && selectedRfqId) { + // 메시지를 읽음으로 처리 + import("@/lib/techsales-rfq/service").then(({ markTechSalesMessagesAsRead }) => { + markTechSalesMessagesAsRead(selectedRfqId, selectedVendor.vendorId || undefined).catch(error => { + console.error("메시지 읽음 처리 오류:", error); + }); + }); + + // 해당 벤더의 읽지 않은 메시지를 0으로 즉시 업데이트 + setUnreadMessages(prev => ({ + ...prev, + [selectedVendor.vendorId!]: 0 + })); + + // 전체 읽지 않은 메시지 개수 갱신 + loadUnreadMessages(); + } + }, [selectedVendor, selectedRfqId, loadUnreadMessages]); if (!selectedRfq) { return ( @@ -439,11 +488,11 @@ export function RfqDetailTables({ selectedRfq, maxHeight }: RfqDetailTablesProps {selectedRows.length}개 선택됨 )} - {totalUnreadMessages > 0 && ( + {/* {totalUnreadMessages > 0 && ( 읽지 않은 메시지: {totalUnreadMessages}건 - )} + )} */} {vendorsWithQuotations > 0 && ( 견적 제출: {vendorsWithQuotations}개 벤더 @@ -471,7 +520,7 @@ export function RfqDetailTables({ selectedRfq, maxHeight }: RfqDetailTablesProps + ); + }, meta: { excelHeader: "프로젝트명" }, @@ -320,30 +319,5 @@ export function getColumns({ enableResizing: true, size: 160, }, - { - id: "actions", - header: ({ column }) => ( - - ), - cell: ({ row }) => { - return ( - - ); - }, - enableSorting: false, - enableHiding: false, - enableResizing: false, - size: 120, - minSize: 120, - maxSize: 120, - }, ] } \ No newline at end of file diff --git a/lib/techsales-rfq/table/rfq-table.tsx b/lib/techsales-rfq/table/rfq-table.tsx index 496d7901..c79e2ecf 100644 --- a/lib/techsales-rfq/table/rfq-table.tsx +++ b/lib/techsales-rfq/table/rfq-table.tsx @@ -330,11 +330,6 @@ export function RFQListTable({ label: "자재명", type: "text", }, - { - id: "pspid", - label: "프로젝트 ID", - type: "text", - }, { id: "projNm", label: "프로젝트명", -- cgit v1.2.3