diff options
| author | dujinkim <dujin.kim@dtsolution.co.kr> | 2025-05-29 10:25:22 +0000 |
|---|---|---|
| committer | dujinkim <dujin.kim@dtsolution.co.kr> | 2025-05-29 10:25:22 +0000 |
| commit | 3bdd648ad4cb863043db181291ddaebbc025965b (patch) | |
| tree | 7197902b7eb6e442b2c62e1cb4e8e9e2553d4def /lib | |
| parent | 0257350f55c00735cadbd5b507ef5cc9cd3adb10 (diff) | |
(김준회) 기술영업 조선 RFQ 변경 (셀 클릭 상세보기 및 Unread Message, Action Column 관련 변경사항 적용)
Diffstat (limited to 'lib')
| -rw-r--r-- | lib/techsales-rfq/service.ts | 40 | ||||
| -rw-r--r-- | lib/techsales-rfq/table/delete-vendors-dialog.tsx | 119 | ||||
| -rw-r--r-- | lib/techsales-rfq/table/detail-table/delete-vendors-dialog.tsx (renamed from lib/techsales-rfq/table/detail-table/delete-vendor-dialog.tsx) | 0 | ||||
| -rw-r--r-- | lib/techsales-rfq/table/detail-table/rfq-detail-column.tsx | 70 | ||||
| -rw-r--r-- | lib/techsales-rfq/table/detail-table/rfq-detail-table.tsx | 128 | ||||
| -rw-r--r-- | lib/techsales-rfq/table/rfq-table-column.tsx | 54 | ||||
| -rw-r--r-- | lib/techsales-rfq/table/rfq-table.tsx | 5 |
7 files changed, 302 insertions, 114 deletions
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 @@ -2490,6 +2490,46 @@ export interface TechSalesComment { } /** + * 특정 RFQ의 벤더별 읽지 않은 메시지 개수를 조회하는 함수 + * + * @param rfqId RFQ ID + * @returns 벤더별 읽지 않은 메시지 개수 (vendorId: count) + */ +export async function getTechSalesUnreadMessageCounts(rfqId: number): Promise<Record<number, number>> { + try { + // 벤더가 보낸 읽지 않은 메시지를 벤더별로 카운트 + const unreadCounts = await db + .select({ + vendorId: techSalesRfqComments.vendorId, + count: sql<number>`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<number, number> 형태로 변환 + const result: Record<number, number> = {}; + unreadCounts.forEach(item => { + if (item.vendorId) { + result[item.vendorId] = item.count; + } + }); + + return result; + } catch (error) { + console.error('techSales 읽지 않은 메시지 개수 조회 오류:', error); + return {}; + } +} + +/** * 특정 RFQ와 벤더 간의 커뮤니케이션 메시지를 가져오는 서버 액션 * * @param rfqId RFQ ID 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<typeof Dialog> { + 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 ( + <Dialog {...props}> + <DialogContent> + <DialogHeader> + <DialogTitle>벤더 삭제 확인</DialogTitle> + <DialogDescription> + 정말로 선택한 <span className="font-medium">{vendors.length}개</span>의 벤더를 삭제하시겠습니까? + <br /> + <br /> + 삭제될 벤더: <span className="font-medium">{vendorNames}</span> + <br /> + <br /> + 이 작업은 되돌릴 수 없습니다. + </DialogDescription> + </DialogHeader> + <DialogFooter className="gap-2 sm:space-x-0"> + <DialogClose asChild> + <Button variant="outline" disabled={isLoading}>취소</Button> + </DialogClose> + <Button + aria-label="선택한 벤더들 삭제" + variant="destructive" + onClick={onConfirm} + disabled={isLoading} + > + {isLoading && ( + <Loader + className="mr-2 size-4 animate-spin" + aria-hidden="true" + /> + )} + 삭제 + </Button> + </DialogFooter> + </DialogContent> + </Dialog> + ) + } + + return ( + <Drawer {...props}> + <DrawerContent> + <DrawerHeader> + <DrawerTitle>벤더 삭제 확인</DrawerTitle> + <DrawerDescription> + 정말로 선택한 <span className="font-medium">{vendors.length}개</span>의 벤더를 삭제하시겠습니까? + <br /> + <br /> + 삭제될 벤더: <span className="font-medium">{vendorNames}</span> + <br /> + <br /> + 이 작업은 되돌릴 수 없습니다. + </DrawerDescription> + </DrawerHeader> + <DrawerFooter className="gap-2 sm:space-x-0"> + <DrawerClose asChild> + <Button variant="outline" disabled={isLoading}>취소</Button> + </DrawerClose> + <Button + aria-label="선택한 벤더들 삭제" + variant="destructive" + onClick={onConfirm} + disabled={isLoading} + > + {isLoading && ( + <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/delete-vendor-dialog.tsx b/lib/techsales-rfq/table/detail-table/delete-vendors-dialog.tsx index d7e3403b..d7e3403b 100644 --- a/lib/techsales-rfq/table/detail-table/delete-vendor-dialog.tsx +++ b/lib/techsales-rfq/table/detail-table/delete-vendors-dialog.tsx 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<TData> { row: Row<TData>; - type: "delete" | "communicate"; + type: "communicate" | "delete"; } // 벤더 견적 데이터 타입 정의 @@ -128,7 +127,28 @@ export function getRfqDetailColumns({ header: ({ column }) => ( <DataTableColumnHeaderSimple column={column} title="벤더명" /> ), - cell: ({ row }) => <div>{row.getValue("vendorName")}</div>, + cell: ({ row }) => { + const vendorName = row.getValue("vendorName") as string | null; + const vendorId = row.original.vendorId; + + if (!vendorName) return <div>-</div>; + + if (vendorId) { + return ( + <Button + variant="link" + className="p-0 h-auto font-normal text-left justify-start hover:underline" + onClick={() => { + window.open(`/ko/evcp/vendors/${vendorId}/info`, '_blank'); + }} + > + {vendorName} + </Button> + ); + } + + return <div>{vendorName}</div>; + }, 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 ( <div className="text-right flex items-center justify-end gap-1"> @@ -264,31 +279,26 @@ export function getRfqDetailColumns({ )} </div> - {/* 기존 드롭다운 메뉴 */} + {/* 컨텍스트 메뉴 */} <DropdownMenu> <DropdownMenuTrigger asChild> <Button variant="ghost" - className="flex h-8 w-8 p-0 data-[state=open]:bg-muted" + size="sm" + className="h-8 w-8 p-0" + title="더 많은 작업" > - <Ellipsis className="h-4 w-4" /> - <span className="sr-only">메뉴 열기</span> + <MoreHorizontal className="h-4 w-4" /> </Button> </DropdownMenuTrigger> - <DropdownMenuContent align="end" className="w-[160px]"> - <DropdownMenuItem - onClick={handleViewDetails} - disabled={!vendorId} - className="gap-2" - > - {/* <Eye className="h-4 w-4" /> */} - 벤더 상세정보 - </DropdownMenuItem> + <DropdownMenuContent align="end"> <DropdownMenuItem onClick={() => setRowAction({ row, type: "delete" })} - className="text-destructive focus:text-destructive" + disabled={!isDraft} + className={!isDraft ? "opacity-50 cursor-not-allowed" : "text-destructive focus:text-destructive"} > - 벤더 제거 + <Trash2 className="mr-2 h-4 w-4" /> + 벤더 삭제 </DropdownMenuItem> </DropdownMenuContent> </DropdownMenu> @@ -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<RfqDetailView[]>([]) const [vendorDialogOpen, setVendorDialogOpen] = React.useState(false) - const [deleteDialogOpen, setDeleteDialogOpen] = React.useState(false) - const [selectedDetail, setSelectedDetail] = React.useState<RfqDetailView | null>(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}개 선택됨 </Badge> )} - {totalUnreadMessages > 0 && ( + {/* {totalUnreadMessages > 0 && ( <Badge variant="destructive" className="h-6"> 읽지 않은 메시지: {totalUnreadMessages}건 </Badge> - )} + )} */} {vendorsWithQuotations > 0 && ( <Badge variant="outline" className="h-6"> 견적 제출: {vendorsWithQuotations}개 벤더 @@ -471,7 +520,7 @@ export function RfqDetailTables({ selectedRfq, maxHeight }: RfqDetailTablesProps <Button variant="outline" size="sm" - onClick={handleDeleteVendors} + onClick={handleDeleteVendorsConfirm} disabled={selectedRows.length === 0 || isDeletingVendors} className="gap-2" > @@ -549,14 +598,6 @@ export function RfqDetailTables({ selectedRfq, maxHeight }: RfqDetailTablesProps onSuccess={handleRefreshData} /> - <DeleteVendorDialog - open={deleteDialogOpen} - onOpenChange={setDeleteDialogOpen} - detail={selectedDetail} - showTrigger={false} - onSuccess={handleRefreshData} - /> - {/* 벤더 커뮤니케이션 드로어 */} <VendorCommunicationDrawer open={communicationDrawerOpen} @@ -572,6 +613,15 @@ export function RfqDetailTables({ selectedRfq, maxHeight }: RfqDetailTablesProps onOpenChange={setComparisonDialogOpen} selectedRfq={selectedRfq} /> + + {/* 다중 벤더 삭제 확인 다이얼로그 */} + <DeleteVendorsDialog + open={deleteConfirmDialogOpen} + onOpenChange={setDeleteConfirmDialogOpen} + vendors={selectedRows} + onConfirm={executeDeleteVendors} + isLoading={isDeletingVendors} + /> </div> ) }
\ No newline at end of file diff --git a/lib/techsales-rfq/table/rfq-table-column.tsx b/lib/techsales-rfq/table/rfq-table-column.tsx index e1047fd1..4f7bd499 100644 --- a/lib/techsales-rfq/table/rfq-table-column.tsx +++ b/lib/techsales-rfq/table/rfq-table-column.tsx @@ -6,7 +6,7 @@ import { formatDate, formatDateTime } from "@/lib/utils" import { Checkbox } from "@/components/ui/checkbox" import { DataTableColumnHeaderSimple } from "@/components/data-table/data-table-column-simple-header" import { DataTableRowAction } from "@/types/table" -import { Info, Paperclip } from "lucide-react" +import { Paperclip } from "lucide-react" import { Button } from "@/components/ui/button" // 기본적인 RFQ 타입 정의 (rfq-table.tsx 파일과 일치해야 함) @@ -135,7 +135,7 @@ export function getColumns({ <DataTableColumnHeaderSimple column={column} title="자재명" /> ), cell: ({ row }) => { - const itemName = row.getValue("itemName"); + const itemName = row.getValue("itemName") as string | null; return <div>{itemName || "자재명 없음"}</div>; }, meta: { @@ -145,23 +145,22 @@ export function getColumns({ size: 180, }, { - accessorKey: "pspid", - header: ({ column }) => ( - <DataTableColumnHeaderSimple column={column} title="프로젝트 번호" /> - ), - cell: ({ row }) => <div>{row.getValue("pspid")}</div>, - meta: { - excelHeader: "프로젝트 번호" - }, - enableResizing: true, - size: 120, - }, - { accessorKey: "projNm", header: ({ column }) => ( <DataTableColumnHeaderSimple column={column} title="프로젝트명" /> ), - cell: ({ row }) => <div>{row.getValue("projNm")}</div>, + cell: ({ row }) => { + const projNm = row.getValue("projNm") as string; + return ( + <Button + variant="link" + className="p-0 h-auto font-normal text-left justify-start hover:underline" + onClick={() => setRowAction({ row, type: "view" as const })} + > + {projNm} + </Button> + ); + }, meta: { excelHeader: "프로젝트명" }, @@ -320,30 +319,5 @@ export function getColumns({ enableResizing: true, size: 160, }, - { - id: "actions", - header: ({ column }) => ( - <DataTableColumnHeaderSimple column={column} title="액션" /> - ), - cell: ({ row }) => { - return ( - <Button - variant="ghost" - size="sm" - onClick={() => setRowAction({ row, type: "view" as const })} - className="h-8 px-2 gap-1" - > - <Info className="h-4 w-4" /> - <span className="hidden sm:inline">프로젝트 상세</span> - </Button> - ); - }, - 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 @@ -331,11 +331,6 @@ export function RFQListTable({ type: "text", }, { - id: "pspid", - label: "프로젝트 ID", - type: "text", - }, - { id: "projNm", label: "프로젝트명", type: "text", |
