diff options
| author | dujinkim <dujin.kim@dtsolution.co.kr> | 2025-07-09 06:27:10 +0000 |
|---|---|---|
| committer | dujinkim <dujin.kim@dtsolution.co.kr> | 2025-07-09 06:27:10 +0000 |
| commit | 44794a8628997c0d979adb5bd6711cd848b3e397 (patch) | |
| tree | 2d614786aaedf0f26a3ea390d0e2275acba3cacb | |
| parent | bcc7603a7aea83341728557445fb4bc78129cca2 (diff) | |
(최겸) 기술영업 판교 미팅 이전 rfq-tech 삭제
47 files changed, 0 insertions, 13266 deletions
diff --git a/lib/cbe-tech/table/cbe-table-columns.tsx b/lib/cbe-tech/table/cbe-table-columns.tsx deleted file mode 100644 index 2da62ea8..00000000 --- a/lib/cbe-tech/table/cbe-table-columns.tsx +++ /dev/null @@ -1,241 +0,0 @@ -"use client" - -import * as React from "react" -import { type DataTableRowAction } from "@/types/table" -import { type ColumnDef } from "@tanstack/react-table" -import { Download, Ellipsis, MessageSquare } from "lucide-react" -import { toast } from "sonner" - -import { getErrorMessage } from "@/lib/handle-error" -import { formatDate } from "@/lib/utils" -import { Badge } from "@/components/ui/badge" -import { Button } from "@/components/ui/button" -import { Checkbox } from "@/components/ui/checkbox" -import { - DropdownMenu, - DropdownMenuContent, - DropdownMenuRadioGroup, - DropdownMenuRadioItem, - DropdownMenuSub, - DropdownMenuSubContent, - DropdownMenuSubTrigger, - DropdownMenuTrigger, -} from "@/components/ui/dropdown-menu" -import { DataTableColumnHeaderSimple } from "@/components/data-table/data-table-column-simple-header" -import { useRouter } from "next/navigation" - -import { VendorWithCbeFields,vendorCbeColumnsConfig } from "@/config/vendorCbeColumnsConfig" - - -type NextRouter = ReturnType<typeof useRouter> - -interface GetColumnsProps { - setRowAction: React.Dispatch< - React.SetStateAction<DataTableRowAction<VendorWithCbeFields> | null> - > - router: NextRouter - openCommentSheet: (responseId: number) => void - openVendorContactsDialog: (vendorId: number, vendor: VendorWithCbeFields) => void // 수정된 시그니처 - -} - -/** - * tanstack table 컬럼 정의 (중첩 헤더 버전) - */ -export function getColumns({ - setRowAction, - router, - openCommentSheet, - openVendorContactsDialog -}: GetColumnsProps): ColumnDef<VendorWithCbeFields>[] { - // ---------------------------------------------------------------- - // 1) Select 컬럼 (체크박스) - // ---------------------------------------------------------------- - const selectColumn: ColumnDef<VendorWithCbeFields> = { - id: "select", - header: ({ table }) => ( - <Checkbox - checked={ - table.getIsAllPageRowsSelected() || - (table.getIsSomePageRowsSelected() && "indeterminate") - } - onCheckedChange={(value) => table.toggleAllPageRowsSelected(!!value)} - aria-label="Select all" - className="translate-y-0.5" - /> - ), - cell: ({ row }) => ( - <Checkbox - checked={row.getIsSelected()} - onCheckedChange={(value) => row.toggleSelected(!!value)} - aria-label="Select row" - className="translate-y-0.5" - /> - ), - size: 40, - enableSorting: false, - enableHiding: false, - } - - // ---------------------------------------------------------------- - // 2) 그룹화(Nested) 컬럼 구성 - // ---------------------------------------------------------------- - const groupMap: Record<string, ColumnDef<VendorWithCbeFields>[]> = {} - - vendorCbeColumnsConfig.forEach((cfg) => { - const groupName = cfg.group || "_noGroup" - if (!groupMap[groupName]) { - groupMap[groupName] = [] - } - - // childCol: ColumnDef<VendorWithTbeFields> - const childCol: ColumnDef<VendorWithCbeFields> = { - accessorKey: cfg.id, - enableResizing: true, - header: ({ column }) => ( - <DataTableColumnHeaderSimple column={column} title={cfg.label} /> - ), - meta: { - excelHeader: cfg.excelHeader, - group: cfg.group, - type: cfg.type, - }, - // 셀 렌더링 - cell: ({ row, getValue }) => { - // 1) 필드값 가져오기 - const val = getValue() - - if (cfg.id === "vendorName") { - const vendor = row.original; - const vendorId = vendor.vendorId; - - // 협력업체 이름을 클릭할 수 있는 버튼으로 렌더링 - const handleVendorNameClick = () => { - if (vendorId) { - openVendorContactsDialog(vendorId, vendor); // vendor 전체 객체 전달 - } else { - toast.error("협력업체 ID를 찾을 수 없습니다."); - } - }; - - return ( - <Button - variant="link" - className="p-0 h-auto text-left font-normal justify-start hover:underline" - onClick={handleVendorNameClick} - > - {val as string} - </Button> - ); - } - - - if (cfg.id === "vendorStatus") { - const statusVal = row.original.vendorStatus - if (!statusVal) return null - // const Icon = getStatusIcon(statusVal) - return ( - <Badge variant="outline"> - {statusVal} - </Badge> - ) - } - - - if (cfg.id === "responseStatus") { - const statusVal = row.original.responseStatus - if (!statusVal) return null - // const Icon = getStatusIcon(statusVal) - const variant = statusVal ==="INVITED"?"default" :statusVal ==="DECLINED"?"destructive":statusVal ==="ACCEPTED"?"secondary":"outline" - return ( - <Badge variant={variant}> - {statusVal} - </Badge> - ) - } - - // 예) CBE Updated (날짜) - if (cfg.id === "respondedAt") { - const dateVal = val as Date | undefined - if (!dateVal) return null - return formatDate(dateVal) - } - - // 그 외 필드는 기본 값 표시 - return val ?? "" - }, - } - - groupMap[groupName].push(childCol) - }) - - // groupMap → nestedColumns - const nestedColumns: ColumnDef<VendorWithCbeFields>[] = [] - Object.entries(groupMap).forEach(([groupName, colDefs]) => { - if (groupName === "_noGroup") { - nestedColumns.push(...colDefs) - } else { - nestedColumns.push({ - id: groupName, - header: groupName, - columns: colDefs, - }) - } - }) - -// 댓글 칼럼 -const commentsColumn: ColumnDef<VendorWithCbeFields> = { - id: "comments", - header: ({ column }) => ( - <DataTableColumnHeaderSimple column={column} title="Comments" /> - ), - cell: ({ row }) => { - const vendor = row.original - const commCount = vendor.comments?.length ?? 0 - - function handleClick() { - // setRowAction() 로 type 설정 - setRowAction({ row, type: "comments" }) - // 필요하면 즉시 openCommentSheet() 직접 호출 - openCommentSheet(vendor.responseId ?? 0) - } - - return ( - <Button - variant="ghost" - size="sm" - className="relative h-8 w-8 p-0 group" - onClick={handleClick} - aria-label={ - commCount > 0 ? `View ${commCount} comments` : "No comments" - } - > - <MessageSquare className="h-4 w-4 text-muted-foreground group-hover:text-primary transition-colors" /> - {commCount > 0 && ( - <Badge - variant="secondary" - className="pointer-events-none absolute -top-1 -right-1 h-4 min-w-[1rem] p-0 text-[0.625rem] leading-none flex items-center justify-center" - > - {commCount} - </Badge> - )} - <span className="sr-only"> - {commCount > 0 ? `${commCount} Comments` : "No Comments"} - </span> - </Button> - ) - }, - enableSorting: false, - minSize: 80, -} -// ---------------------------------------------------------------- -// 5) 최종 컬럼 배열 - Update to include the files column -// ---------------------------------------------------------------- -return [ - selectColumn, - ...nestedColumns, - commentsColumn, - // actionsColumn, -] - -}
\ No newline at end of file diff --git a/lib/cbe-tech/table/cbe-table-toolbar-actions.tsx b/lib/cbe-tech/table/cbe-table-toolbar-actions.tsx deleted file mode 100644 index 44a79b37..00000000 --- a/lib/cbe-tech/table/cbe-table-toolbar-actions.tsx +++ /dev/null @@ -1,72 +0,0 @@ -"use client" - -import * as React from "react" -import { type Table } from "@tanstack/react-table" -import { Download, Upload } from "lucide-react" -import { toast } from "sonner" - -import { exportTableToExcel } from "@/lib/export" -import { Button } from "@/components/ui/button" - - -import { VendorWithCbeFields } from "@/config/vendorCbeColumnsConfig" -import { InviteVendorsDialog } from "@/lib/rfqs-tech/cbe-table/invite-vendors-dialog" - -interface VendorsTableToolbarActionsProps { - table: Table<VendorWithCbeFields> - rfqId: number -} - -export function VendorsTableToolbarActions({ table, rfqId }: VendorsTableToolbarActionsProps) { - // 파일 input을 숨기고, 버튼 클릭 시 참조해 클릭하는 방식 - const fileInputRef = React.useRef<HTMLInputElement>(null) - - // 파일이 선택되었을 때 처리 - - function handleImportClick() { - // 숨겨진 <input type="file" /> 요소를 클릭 - fileInputRef.current?.click() - } - - const uniqueRfqIds = table.getFilteredSelectedRowModel().rows.length > 0 - ? [...new Set(table.getFilteredSelectedRowModel().rows.map(row => row.original.rfqId))] - : []; - -const hasMultipleRfqIds = uniqueRfqIds.length > 1; - -const invitationPossibeVendors = React.useMemo(() => { - return table - .getFilteredSelectedRowModel() - .rows - .map(row => row.original) - .filter(vendor => vendor.commercialResponseStatus === null); -}, [table.getFilteredSelectedRowModel().rows]); - -return ( - <div className="flex items-center gap-2"> - {invitationPossibeVendors.length > 0 && ( - <InviteVendorsDialog - vendors={invitationPossibeVendors} - rfqId={rfqId} - onSuccess={() => table.toggleAllRowsSelected(false)} - showTrigger={true} - /> - )} - - <Button - variant="outline" - size="sm" - onClick={() => - exportTableToExcel(table, { - filename: "tasks", - excludeColumns: ["select", "actions"], - }) - } - className="gap-2" - > - <Download className="size-4" aria-hidden="true" /> - <span className="hidden sm:inline">Export</span> - </Button> - </div> - ) -}
\ No newline at end of file diff --git a/lib/cbe-tech/table/cbe-table.tsx b/lib/cbe-tech/table/cbe-table.tsx deleted file mode 100644 index 0cd5aec0..00000000 --- a/lib/cbe-tech/table/cbe-table.tsx +++ /dev/null @@ -1,192 +0,0 @@ -"use client" - -import * as React from "react" -import { useRouter } from "next/navigation" -import type { - DataTableAdvancedFilterField, - DataTableFilterField, - DataTableRowAction, -} from "@/types/table" - -import { toSentenceCase } from "@/lib/utils" -import { useDataTable } from "@/hooks/use-data-table" -import { DataTable } from "@/components/data-table/data-table" -import { DataTableAdvancedToolbar } from "@/components/data-table/data-table-advanced-toolbar" -import { getColumns } from "./cbe-table-columns" -import { CommentSheet, CbeComment } from "@/lib/rfqs-tech/cbe-table/comments-sheet" -import { VendorWithCbeFields } from "@/config/vendorCbeColumnsConfig" -import { fetchRfqAttachmentsbyCommentId, getAllCBE } from "@/lib/rfqs-tech/service" -import { VendorsTableToolbarActions } from "./cbe-table-toolbar-actions" -import { InviteVendorsDialog } from "@/lib/rfqs-tech/cbe-table/invite-vendors-dialog" -import { VendorContactsDialog } from "@/lib/rfqs-tech/cbe-table/vendor-contact-dialog" -import { useSession } from "next-auth/react" // Next-auth session hook 추가 - - - -import { toast } from "sonner" - -interface VendorsTableProps { - promises: Promise<[ - Awaited<ReturnType<typeof getAllCBE>>, - ]> -} - -export function AllCbeTable({ promises }: VendorsTableProps) { - - // Suspense로 받아온 데이터 - const [{ data, pageCount }] = React.use(promises) - const { data: session } = useSession() // 세션 정보 가져오기 - - const currentUserId = session?.user?.id ? parseInt(session.user.id, 10) : 0 - const currentUser = session?.user - - const [rowAction, setRowAction] = React.useState<DataTableRowAction<VendorWithCbeFields> | null>(null) - // **router** 획득 - const router = useRouter() - // 댓글 시트 관련 state - const [initialComments, setInitialComments] = React.useState<CbeComment[]>([]) - const [isLoadingComments, setIsLoadingComments] = React.useState(false) - - const [commentSheetOpen, setCommentSheetOpen] = React.useState(false) - const [selectedVendorId, setSelectedVendorId] = React.useState<number | null>(null) - const [selectedCbeId, setSelectedCbeId] = React.useState<number | null>(null) - const [isContactDialogOpen, setIsContactDialogOpen] = React.useState(false) - const [selectedVendor, setSelectedVendor] = React.useState<VendorWithCbeFields | null>(null) - const [selectedRfqId, setSelectedRfqId] = React.useState<number | null>(null) - - // ----------------------------------------------------------- - // 특정 action이 설정될 때마다 실행되는 effect - // ----------------------------------------------------------- - React.useEffect(() => { - if (rowAction?.type === "comments") { - // rowAction가 새로 세팅된 뒤 여기서 openCommentSheet 실행 - openCommentSheet(Number(rowAction.row.original.responseId)) - } - }, [rowAction]) - - // ----------------------------------------------------------- - // 댓글 시트 열기 - // ----------------------------------------------------------- - async function openCommentSheet(responseId: number) { - setInitialComments([]) - setIsLoadingComments(true) - const comments = rowAction?.row.original.comments - const rfqId = rowAction?.row.original.rfqId - const vendorId = rowAction?.row.original.vendorId - try { - if (comments && comments.length > 0) { - const commentWithAttachments: CbeComment[] = await Promise.all( - comments.map(async (c) => { - const attachments = await fetchRfqAttachmentsbyCommentId(c.id) - - return { - ...c, - commentedBy: currentUserId, // DB나 API 응답에 있다고 가정 - attachments, - } - }) - ) - // 3) state에 저장 -> CommentSheet에서 initialComments로 사용 - setInitialComments(commentWithAttachments) - } - - if(vendorId){ setSelectedVendorId(vendorId)} - if(rfqId){ setSelectedRfqId(rfqId)} - setSelectedCbeId(responseId) - setCommentSheetOpen(true) - }catch (error) { - console.error("Error loading comments:", error) - toast.error("Failed to load comments") - } finally { - // End loading regardless of success/failure - setIsLoadingComments(false) - } -} - -const openVendorContactsDialog = (vendorId: number, vendor: VendorWithCbeFields) => { - setSelectedVendorId(vendorId) - setSelectedVendor(vendor) - setIsContactDialogOpen(true) -} - - // ----------------------------------------------------------- - // 테이블 컬럼 - // ----------------------------------------------------------- - const columns = React.useMemo( - () => getColumns({ setRowAction, router, openCommentSheet, openVendorContactsDialog }), - [setRowAction, router] - ) - - // ----------------------------------------------------------- - // 필터 필드 - // ----------------------------------------------------------- - const filterFields: DataTableFilterField<VendorWithCbeFields>[] = [ - // 예: 표준 필터 - ] - const advancedFilterFields: DataTableAdvancedFilterField<VendorWithCbeFields>[] = [ - { id: "vendorName", label: "Vendor Name", type: "text" }, - { id: "vendorCode", label: "Vendor Code", type: "text" }, - { id: "respondedAt", label: "Updated at", type: "date" }, - ] - - // ----------------------------------------------------------- - // 테이블 생성 훅 - // ----------------------------------------------------------- - const { table } = useDataTable({ - data, - columns, - pageCount, - filterFields, - enablePinning: true, - enableAdvancedFilter: true, - initialState: { - sorting: [{ id: "respondedAt", desc: true }], - columnPinning: { right: ["comments"] }, - }, - getRowId: (originalRow) => (`${originalRow.vendorId}${originalRow.rfqId}`), - shallow: false, - clearOnDefault: true, - }) - - return ( - <> - <DataTable table={table}> - <DataTableAdvancedToolbar - table={table} - filterFields={advancedFilterFields} - shallow={false} - > - <VendorsTableToolbarActions table={table} rfqId={selectedRfqId ?? 0} /> - </DataTableAdvancedToolbar> - </DataTable> - - {/* 댓글 시트 */} - <CommentSheet - currentUserId={currentUserId} - open={commentSheetOpen} - onOpenChange={setCommentSheetOpen} - vendorId={selectedVendorId ?? 0} - rfqId={selectedRfqId ?? 0} - cbeId={selectedCbeId ?? 0} - isLoading={isLoadingComments} - initialComments={initialComments} - /> - - <InviteVendorsDialog - vendors={rowAction?.row.original ? [rowAction?.row.original] : []} - onOpenChange={() => setRowAction(null)} - rfqId={selectedRfqId ?? 0} - open={rowAction?.type === "invite"} - showTrigger={false} - currentUser={currentUser} - /> - - <VendorContactsDialog - isOpen={isContactDialogOpen} - onOpenChange={setIsContactDialogOpen} - vendorId={selectedVendorId} - vendor={selectedVendor} - /> - </> - ) -}
\ No newline at end of file diff --git a/lib/rfqs-tech/cbe-table/cbe-table-columns.tsx b/lib/rfqs-tech/cbe-table/cbe-table-columns.tsx deleted file mode 100644 index 11ce9ccf..00000000 --- a/lib/rfqs-tech/cbe-table/cbe-table-columns.tsx +++ /dev/null @@ -1,257 +0,0 @@ -"use client" - -import * as React from "react" -import { type DataTableRowAction } from "@/types/table" -import { type ColumnDef } from "@tanstack/react-table" -import { Download, Ellipsis, MessageSquare } from "lucide-react" -import { toast } from "sonner" - -import { getErrorMessage } from "@/lib/handle-error" -import { formatDate } from "@/lib/utils" -import { Badge } from "@/components/ui/badge" -import { Button } from "@/components/ui/button" -import { Checkbox } from "@/components/ui/checkbox" -import { - DropdownMenu, - DropdownMenuContent, - DropdownMenuRadioGroup, - DropdownMenuRadioItem, - DropdownMenuSub, - DropdownMenuSubContent, - DropdownMenuSubTrigger, - DropdownMenuTrigger, -} from "@/components/ui/dropdown-menu" -import { DataTableColumnHeaderSimple } from "@/components/data-table/data-table-column-simple-header" -import { useRouter } from "next/navigation" - - -import { VendorWithCbeFields,vendorCbeColumnsConfig } from "@/config/vendorCbeColumnsConfig" - -type NextRouter = ReturnType<typeof useRouter> - -interface GetColumnsProps { - setRowAction: React.Dispatch< - React.SetStateAction<DataTableRowAction<VendorWithCbeFields> | null> - > - router: NextRouter - openCommentSheet: (responseId: number) => void - openVendorContactsDialog: (vendorId: number, vendor: VendorWithCbeFields) => void // 수정된 시그니처 - -} - -/** - * tanstack table 컬럼 정의 (중첩 헤더 버전) - */ -export function getColumns({ - setRowAction, - router, - openCommentSheet, - openVendorContactsDialog -}: GetColumnsProps): ColumnDef<VendorWithCbeFields>[] { - // ---------------------------------------------------------------- - // 1) Select 컬럼 (체크박스) - // ---------------------------------------------------------------- - const selectColumn: ColumnDef<VendorWithCbeFields> = { - id: "select", - header: ({ table }) => ( - <Checkbox - checked={ - table.getIsAllPageRowsSelected() || - (table.getIsSomePageRowsSelected() && "indeterminate") - } - onCheckedChange={(value) => table.toggleAllPageRowsSelected(!!value)} - aria-label="Select all" - className="translate-y-0.5" - /> - ), - cell: ({ row }) => ( - <Checkbox - checked={row.getIsSelected()} - onCheckedChange={(value) => row.toggleSelected(!!value)} - aria-label="Select row" - className="translate-y-0.5" - /> - ), - size: 40, - enableSorting: false, - enableHiding: false, - } - - // ---------------------------------------------------------------- - // 2) 그룹화(Nested) 컬럼 구성 - // ---------------------------------------------------------------- - const groupMap: Record<string, ColumnDef<VendorWithCbeFields>[]> = {} - - vendorCbeColumnsConfig.forEach((cfg) => { - const groupName = cfg.group || "_noGroup" - if (!groupMap[groupName]) { - groupMap[groupName] = [] - } - - // childCol: ColumnDef<VendorWithCbeFields> - const childCol: ColumnDef<VendorWithCbeFields> = { - accessorKey: cfg.id, - enableResizing: true, - header: ({ column }) => ( - <DataTableColumnHeaderSimple column={column} title={cfg.label} /> - ), - meta: { - excelHeader: cfg.excelHeader, - group: cfg.group, - type: cfg.type, - }, - // 셀 렌더링 - cell: ({ row, getValue }) => { - // 1) 필드값 가져오기 - const val = getValue() - - if (cfg.id === "vendorName") { - const vendor = row.original; - const vendorId = vendor.vendorId; - - // 협력업체 이름을 클릭할 수 있는 버튼으로 렌더링 - const handleVendorNameClick = () => { - if (vendorId) { - openVendorContactsDialog(vendorId, vendor); // vendor 전체 객체 전달 - } else { - toast.error("협력업체 ID를 찾을 수 없습니다."); - } - }; - - return ( - <Button - variant="link" - className="p-0 h-auto text-left font-normal justify-start hover:underline" - onClick={handleVendorNameClick} - > - {val as string} - </Button> - ); - } - - if (cfg.id === "vendorStatus") { - const statusVal = row.original.vendorStatus - if (!statusVal) return null - // const Icon = getStatusIcon(statusVal) - return ( - <Badge variant="outline"> - {statusVal} - </Badge> - ) - } - - - if (cfg.id === "responseStatus") { - const statusVal = row.original.responseStatus - if (!statusVal) return null - // const Icon = getStatusIcon(statusVal) - const variant = statusVal ==="INVITED"?"default" :statusVal ==="DECLINED"?"destructive":statusVal ==="ACCEPTED"?"secondary":"outline" - return ( - <Badge variant={variant}> - {statusVal} - </Badge> - ) - } - - // 예) CBE Updated (날짜) - if (cfg.id === "respondedAt" ) { - const dateVal = val as Date | undefined - if (!dateVal) return null - return formatDate(dateVal) - } - - // 그 외 필드는 기본 값 표시 - return val ?? "" - }, - } - - groupMap[groupName].push(childCol) - }) - - // groupMap → nestedColumns - const nestedColumns: ColumnDef<VendorWithCbeFields>[] = [] - Object.entries(groupMap).forEach(([groupName, colDefs]) => { - if (groupName === "_noGroup") { - nestedColumns.push(...colDefs) - } else { - nestedColumns.push({ - id: groupName, - header: groupName, - columns: colDefs, - }) - } - }) - // ---------------------------------------------------------------- - // 2) 액션컬럼 (빈 상태로 유지하여 에러 방지) - // ---------------------------------------------------------------- - const actionsColumn: ColumnDef<VendorWithCbeFields> = { - id: "actions", - enableHiding: false, - cell: () => { - // 빈 셀 반환 (액션 없음) - return null - }, - size: 40, - enableSorting: false, - } -// ---------------------------------------------------------------- -// 3) Comments 컬럼 -// ---------------------------------------------------------------- -const commentsColumn: ColumnDef<VendorWithCbeFields> = { - id: "comments", - header: ({ column }) => ( - <DataTableColumnHeaderSimple column={column} title="Comments" /> - ), - cell: ({ row }) => { - const vendor = row.original - const commCount = vendor.comments?.length ?? 0 - - function handleClick() { - // rowAction + openCommentSheet - setRowAction({ row, type: "comments" }) - openCommentSheet(vendor.responseId ?? 0) - } - - return ( - <Button - variant="ghost" - size="sm" - className="relative h-8 w-8 p-0 group" - onClick={handleClick} - aria-label={ - commCount > 0 ? `View ${commCount} comments` : "No comments" - } - > - <MessageSquare className="h-4 w-4 text-muted-foreground group-hover:text-primary transition-colors" /> - {commCount > 0 && ( - <Badge - variant="secondary" - className="pointer-events-none absolute -top-1 -right-1 h-4 min-w-[1rem] p-0 text-[0.625rem] leading-none flex items-center justify-center" - > - {commCount} - </Badge> - )} - <span className="sr-only"> - {commCount > 0 ? `${commCount} Comments` : "No Comments"} - </span> - </Button> - ) - }, - enableSorting: false, - maxSize:80 -} - - - - -// ---------------------------------------------------------------- -// 5) 최종 컬럼 배열 - Update to include the files column -// ---------------------------------------------------------------- -return [ - selectColumn, - ...nestedColumns, - commentsColumn, - actionsColumn, -] - -}
\ No newline at end of file diff --git a/lib/rfqs-tech/cbe-table/cbe-table-toolbar-actions.tsx b/lib/rfqs-tech/cbe-table/cbe-table-toolbar-actions.tsx deleted file mode 100644 index 464bf988..00000000 --- a/lib/rfqs-tech/cbe-table/cbe-table-toolbar-actions.tsx +++ /dev/null @@ -1,67 +0,0 @@ -"use client" - -import * as React from "react" -import { type Table } from "@tanstack/react-table" -import { Download, Upload } from "lucide-react" -import { toast } from "sonner" - -import { exportTableToExcel } from "@/lib/export" -import { Button } from "@/components/ui/button" - - -import { VendorWithCbeFields } from "@/config/vendorCbeColumnsConfig" -import { InviteVendorsDialog } from "./invite-vendors-dialog" - -interface VendorsTableToolbarActionsProps { - table: Table<VendorWithCbeFields> - rfqId: number -} - -export function VendorsTableToolbarActions({ table, rfqId }: VendorsTableToolbarActionsProps) { - // 파일 input을 숨기고, 버튼 클릭 시 참조해 클릭하는 방식 - const fileInputRef = React.useRef<HTMLInputElement>(null) - - // 파일이 선택되었을 때 처리 - - function handleImportClick() { - // 숨겨진 <input type="file" /> 요소를 클릭 - fileInputRef.current?.click() - } - - const invitationPossibeVendors = React.useMemo(() => { - return table - .getFilteredSelectedRowModel() - .rows - .map(row => row.original) - .filter(vendor => vendor.commercialResponseStatus === null); - }, [table.getFilteredSelectedRowModel().rows]); - - return ( - <div className="flex items-center gap-2"> - {invitationPossibeVendors.length > 0 && - ( - <InviteVendorsDialog - vendors={invitationPossibeVendors} - rfqId={rfqId} - onSuccess={() => table.toggleAllRowsSelected(false)} - /> - ) - } - - <Button - variant="outline" - size="sm" - onClick={() => - exportTableToExcel(table, { - filename: "tasks", - excludeColumns: ["select"], - }) - } - className="gap-2" - > - <Download className="size-4" aria-hidden="true" /> - <span className="hidden sm:inline">Export</span> - </Button> - </div> - ) -}
\ No newline at end of file diff --git a/lib/rfqs-tech/cbe-table/cbe-table.tsx b/lib/rfqs-tech/cbe-table/cbe-table.tsx deleted file mode 100644 index 37fbc3f4..00000000 --- a/lib/rfqs-tech/cbe-table/cbe-table.tsx +++ /dev/null @@ -1,178 +0,0 @@ -"use client" - -import * as React from "react" -import { useRouter } from "next/navigation" -import type { - DataTableAdvancedFilterField, - DataTableFilterField, - DataTableRowAction, -} from "@/types/table" - -import { useDataTable } from "@/hooks/use-data-table" -import { DataTable } from "@/components/data-table/data-table" -import { DataTableAdvancedToolbar } from "@/components/data-table/data-table-advanced-toolbar" -import { fetchRfqAttachmentsbyCommentId, getCBE } from "../service" -import { getColumns } from "./cbe-table-columns" -import { VendorWithCbeFields } from "@/config/vendorCbeColumnsConfig" -import { CommentSheet, CbeComment } from "./comments-sheet" -import { useSession } from "next-auth/react" // Next-auth session hook 추가 -import { VendorContactsDialog } from "./vendor-contact-dialog" -import { InviteVendorsDialog } from "./invite-vendors-dialog" -import { VendorsTableToolbarActions } from "./cbe-table-toolbar-actions" - -interface VendorsTableProps { - promises: Promise< - [ - Awaited<ReturnType<typeof getCBE>>, - ] - > - rfqId: number -} - - -export function CbeTable({ promises, rfqId }: VendorsTableProps) { - - // Suspense로 받아온 데이터 - const [{ data, pageCount }] = React.use(promises) - const { data: session } = useSession() // 세션 정보 가져오기 - - const currentUserId = session?.user?.id ? parseInt(session.user.id, 10) : 0 - const currentUser = session?.user - - - const [rowAction, setRowAction] = React.useState<DataTableRowAction<VendorWithCbeFields> | null>(null) - - // **router** 획득 - const router = useRouter() - - const [initialComments, setInitialComments] = React.useState<CbeComment[]>([]) - const [commentSheetOpen, setCommentSheetOpen] = React.useState(false) - const [isLoadingComments, setIsLoadingComments] = React.useState(false) - // const [selectedRfqIdForComments, setSelectedRfqIdForComments] = React.useState<number | null>(null) - - const [selectedVendorId, setSelectedVendorId] = React.useState<number | null>(null) - const [selectedCbeId, setSelectedCbeId] = React.useState<number | null>(null) - const [isContactDialogOpen, setIsContactDialogOpen] = React.useState(false) - const [selectedVendor, setSelectedVendor] = React.useState<VendorWithCbeFields | null>(null) - // console.log("selectedVendorId", selectedVendorId) - // console.log("selectedCbeId", selectedCbeId) - - React.useEffect(() => { - if (rowAction?.type === "comments") { - // rowAction가 새로 세팅된 뒤 여기서 openCommentSheet 실행 - openCommentSheet(Number(rowAction.row.original.responseId)) - } - }, [rowAction]) - - async function openCommentSheet(responseId: number) { - setInitialComments([]) - setIsLoadingComments(true) - const comments = rowAction?.row.original.comments - // const rfqId = rowAction?.row.original.rfqId - const vendorId = rowAction?.row.original.vendorId - - if (comments && comments.length > 0) { - const commentWithAttachments: CbeComment[] = await Promise.all( - comments.map(async (c) => { - const attachments = await fetchRfqAttachmentsbyCommentId(c.id) - - return { - ...c, - commentedBy: currentUserId, // DB나 API 응답에 있다고 가정 - attachments, - } - }) - ) - // 3) state에 저장 -> CommentSheet에서 initialComments로 사용 - setInitialComments(commentWithAttachments) - } - - // if(rfqId){ setSelectedRfqIdForComments(rfqId)} - if(vendorId){ setSelectedVendorId(vendorId)} - setSelectedCbeId(responseId) - setCommentSheetOpen(true) - setIsLoadingComments(false) - } - - const openVendorContactsDialog = (vendorId: number, vendor: VendorWithCbeFields) => { - setSelectedVendorId(vendorId) - setSelectedVendor(vendor) - setIsContactDialogOpen(true) - } - - // getColumns() 호출 시, router를 주입 - const columns = React.useMemo( - () => getColumns({ setRowAction, router, openCommentSheet, openVendorContactsDialog }), - [setRowAction, router] - ) - - const filterFields: DataTableFilterField<VendorWithCbeFields>[] = [ - ] - - const advancedFilterFields: DataTableAdvancedFilterField<VendorWithCbeFields>[] = [ - { id: "vendorName", label: "Vendor Name", type: "text" }, - { id: "vendorCode", label: "Vendor Code", type: "text" }, - { id: "respondedAt", label: "Updated at", type: "date" }, - ] - - - const { table } = useDataTable({ - data, - columns, - pageCount, - filterFields, - enablePinning: true, - enableAdvancedFilter: true, - initialState: { - sorting: [{ id: "respondedAt", desc: true }], - columnPinning: { right: ["comments"] }, - }, - getRowId: (originalRow) => String(originalRow.responseId), - shallow: false, - clearOnDefault: true, - }) - - return ( - <> - <DataTable - table={table} - > - <DataTableAdvancedToolbar - table={table} - filterFields={advancedFilterFields} - shallow={false} - > - <VendorsTableToolbarActions table={table} rfqId={rfqId} /> - </DataTableAdvancedToolbar> - </DataTable> - - <CommentSheet - currentUserId={currentUserId} - open={commentSheetOpen} - onOpenChange={setCommentSheetOpen} - rfqId={rfqId} - cbeId={selectedCbeId ?? 0} - vendorId={selectedVendorId ?? 0} - isLoading={isLoadingComments} - initialComments={initialComments} - /> - - <InviteVendorsDialog - vendors={rowAction?.row.original ? [rowAction?.row.original] : []} - onOpenChange={() => setRowAction(null)} - rfqId={rfqId} - open={rowAction?.type === "invite"} - showTrigger={false} - currentUser={currentUser} - /> - - <VendorContactsDialog - isOpen={isContactDialogOpen} - onOpenChange={setIsContactDialogOpen} - vendorId={selectedVendorId} - vendor={selectedVendor} - /> - - </> - ) -}
\ No newline at end of file diff --git a/lib/rfqs-tech/cbe-table/comments-sheet.tsx b/lib/rfqs-tech/cbe-table/comments-sheet.tsx deleted file mode 100644 index e91a0617..00000000 --- a/lib/rfqs-tech/cbe-table/comments-sheet.tsx +++ /dev/null @@ -1,328 +0,0 @@ -"use client" - -import * as React from "react" -import { useForm, useFieldArray } from "react-hook-form" -import { z } from "zod" -import { zodResolver } from "@hookform/resolvers/zod" -import { Download, X, Loader2 } from "lucide-react" -import prettyBytes from "pretty-bytes" -import { toast } from "sonner" - -import { - Sheet, - SheetClose, - SheetContent, - SheetDescription, - SheetFooter, - SheetHeader, - SheetTitle, -} from "@/components/ui/sheet" -import { Button } from "@/components/ui/button" -import { - Form, - FormControl, - FormField, - FormItem, - FormLabel, - FormMessage, -} from "@/components/ui/form" -import { Textarea } from "@/components/ui/textarea" -import { - Dropzone, - DropzoneZone, - DropzoneUploadIcon, - DropzoneTitle, - DropzoneDescription, - DropzoneInput, -} from "@/components/ui/dropzone" -import { - Table, - TableHeader, - TableRow, - TableHead, - TableBody, - TableCell, -} from "@/components/ui/table" - -import { createRfqCommentWithAttachments } from "../service" -import { formatDate } from "@/lib/utils" - - -export interface CbeComment { - id: number - commentText: string - commentedBy?: number - commentedByEmail?: string - createdAt?: Date - attachments?: { - id: number - fileName: string - filePath: string - }[] -} - -// 1) props 정의 -interface CommentSheetProps extends React.ComponentPropsWithRef<typeof Sheet> { - initialComments?: CbeComment[] - currentUserId: number - rfqId: number - // tbeId?: number - cbeId?: number - vendorId: number - onCommentsUpdated?: (comments: CbeComment[]) => void - isLoading?: boolean // New prop -} - -// 2) 폼 스키마 -const commentFormSchema = z.object({ - commentText: z.string().min(1, "댓글을 입력하세요."), - newFiles: z.array(z.any()).optional(), // File[] -}) -type CommentFormValues = z.infer<typeof commentFormSchema> - -const MAX_FILE_SIZE = 30e6 // 30MB - -export function CommentSheet({ - rfqId, - vendorId, - initialComments = [], - currentUserId, - // tbeId, - cbeId, - onCommentsUpdated, - isLoading = false, // Default to false - ...props -}: CommentSheetProps) { - - - const [comments, setComments] = React.useState<CbeComment[]>(initialComments) - const [isPending, startTransition] = React.useTransition() - - React.useEffect(() => { - setComments(initialComments) - }, [initialComments]) - - const form = useForm<CommentFormValues>({ - resolver: zodResolver(commentFormSchema), - defaultValues: { - commentText: "", - newFiles: [], - }, - }) - - const { fields: newFileFields, append, remove } = useFieldArray({ - control: form.control, - name: "newFiles", - }) - - // (A) 기존 코멘트 렌더링 - function renderExistingComments() { - - if (isLoading) { - return ( - <div className="flex justify-center items-center h-32"> - <Loader2 className="h-4 w-4 animate-spin text-muted-foreground" /> - <span className="ml-2 text-sm text-muted-foreground">Loading comments...</span> - </div> - ) - } - - if (comments.length === 0) { - return <p className="text-sm text-muted-foreground">No comments yet</p> - } - return ( - <Table> - <TableHeader> - <TableRow> - <TableHead className="w-1/2">Comment</TableHead> - <TableHead>Attachments</TableHead> - <TableHead>Created At</TableHead> - <TableHead>Created By</TableHead> - </TableRow> - </TableHeader> - <TableBody> - {comments.map((c) => ( - <TableRow key={c.id}> - <TableCell>{c.commentText}</TableCell> - <TableCell> - {!c.attachments?.length && ( - <span className="text-sm text-muted-foreground">No files</span> - )} - {c.attachments?.length && ( - <div className="flex flex-col gap-1"> - {c.attachments.map((att) => ( - <div key={att.id} className="flex items-center gap-2"> - <a - href={`/api/rfq-download?path=${encodeURIComponent(att.filePath)}`} - download - target="_blank" - rel="noreferrer" - className="inline-flex items-center gap-1 text-blue-600 underline" - > - <Download className="h-4 w-4" /> - {att.fileName} - </a> - </div> - ))} - </div> - )} - </TableCell> - <TableCell> {c.createdAt ? formatDate(c.createdAt) : "-"}</TableCell> - <TableCell>{c.commentedByEmail ?? "-"}</TableCell> - </TableRow> - ))} - </TableBody> - </Table> - ) - } - - // (B) 파일 드롭 - function handleDropAccepted(files: File[]) { - append(files) - } - - // (C) Submit - async function onSubmit(data: CommentFormValues) { - if (!rfqId) return - startTransition(async () => { - try { - // console.log("rfqId", rfqId) - // console.log("vendorId", vendorId) - // console.log("cbeId", cbeId) - // console.log("currentUserId", currentUserId) - - const res = await createRfqCommentWithAttachments({ - rfqId, - vendorId, - commentText: data.commentText, - commentedBy: currentUserId, - evaluationId: null, - cbeId: cbeId, - files: data.newFiles, - }) - - if (!res.ok) { - throw new Error("Failed to create comment") - } - - toast.success("Comment created") - - // 임시로 새 코멘트 추가 - const newComment: CbeComment = { - id: res.commentId, // 서버 응답 - commentText: data.commentText, - commentedBy: currentUserId, - createdAt: res.createdAt, - attachments: - data.newFiles?.map((f) => ({ - id: Math.floor(Math.random() * 1e6), - fileName: f.name, - filePath: "/uploads/" + f.name, - })) || [], - } - setComments((prev) => [...prev, newComment]) - onCommentsUpdated?.([...comments, newComment]) - - form.reset() - } catch (err: any) { - console.error(err) - toast.error("Error: " + err.message) - } - }) - } - - return ( - <Sheet {...props}> - <SheetContent className="flex flex-col gap-6 sm:max-w-lg"> - <SheetHeader className="text-left"> - <SheetTitle>Comments</SheetTitle> - <SheetDescription> - 필요시 첨부파일과 함께 문의/코멘트를 남길 수 있습니다. - </SheetDescription> - </SheetHeader> - - <div className="max-h-[300px] overflow-y-auto">{renderExistingComments()}</div> - - <Form {...form}> - <form onSubmit={form.handleSubmit(onSubmit)} className="flex flex-col gap-4"> - <FormField - control={form.control} - name="commentText" - render={({ field }) => ( - <FormItem> - <FormLabel>New Comment</FormLabel> - <FormControl> - <Textarea placeholder="Enter your comment..." {...field} /> - </FormControl> - <FormMessage /> - </FormItem> - )} - /> - - <Dropzone - maxSize={MAX_FILE_SIZE} - onDropAccepted={handleDropAccepted} - onDropRejected={(rej) => { - toast.error("File rejected: " + (rej[0]?.file?.name || "")) - }} - > - {({ maxSize }) => ( - <DropzoneZone className="flex justify-center"> - <DropzoneInput /> - <div className="flex items-center gap-6"> - <DropzoneUploadIcon /> - <div className="grid gap-0.5"> - <DropzoneTitle>Drop to attach files</DropzoneTitle> - <DropzoneDescription> - Max size: {prettyBytes(maxSize || 0)} - </DropzoneDescription> - </div> - </div> - </DropzoneZone> - )} - </Dropzone> - - {newFileFields.length > 0 && ( - <div className="flex flex-col gap-2"> - {newFileFields.map((field, idx) => { - const file = form.getValues(`newFiles.${idx}`) - if (!file) return null - return ( - <div - key={field.id} - className="flex items-center justify-between border rounded p-2" - > - <span className="text-sm"> - {file.name} ({prettyBytes(file.size)}) - </span> - <Button - variant="ghost" - size="icon" - type="button" - onClick={() => remove(idx)} - > - <X className="h-4 w-4" /> - </Button> - </div> - ) - })} - </div> - )} - - <SheetFooter className="gap-2 pt-4"> - <SheetClose asChild> - <Button type="button" variant="outline"> - Cancel - </Button> - </SheetClose> - <Button disabled={isPending}> - {isPending && <Loader2 className="mr-2 h-4 w-4 animate-spin" />} - Save - </Button> - </SheetFooter> - </form> - </Form> - </SheetContent> - </Sheet> - ) -}
\ No newline at end of file diff --git a/lib/rfqs-tech/cbe-table/invite-vendors-dialog.tsx b/lib/rfqs-tech/cbe-table/invite-vendors-dialog.tsx deleted file mode 100644 index 18edbe80..00000000 --- a/lib/rfqs-tech/cbe-table/invite-vendors-dialog.tsx +++ /dev/null @@ -1,423 +0,0 @@ -"use client" - -import * as React from "react" -import { useForm } from "react-hook-form" -import { zodResolver } from "@hookform/resolvers/zod" -import { Loader, Send, User } from "lucide-react" -import { toast } from "sonner" -import { z } from "zod" - -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 { Input } from "@/components/ui/input" -import { Textarea } from "@/components/ui/textarea" -import { - Form, - FormControl, - FormField, - FormItem, - FormLabel, - FormMessage, - FormDescription, -} from "@/components/ui/form" -import { type Row } from "@tanstack/react-table" -import { Badge } from "@/components/ui/badge" -import { ScrollArea } from "@/components/ui/scroll-area" - -import { VendorWithCbeFields } from "@/config/vendorCbeColumnsConfig" -import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar" -import { createCbeEvaluation } from "../service" - -// 컴포넌트 내부에서 사용할 폼 스키마 정의 -const formSchema = z.object({ - paymentTerms: z.string().min(1, "결제 조건을 입력하세요"), - incoterms: z.string().min(1, "Incoterms를 입력하세요"), - deliverySchedule: z.string().min(1, "배송 일정을 입력하세요"), - notes: z.string().optional(), -}) - -type FormValues = z.infer<typeof formSchema> - -interface InviteVendorsDialogProps - extends React.ComponentPropsWithoutRef<typeof Dialog> { - rfqId: number - vendors: Row<VendorWithCbeFields>["original"][] - currentUserId?: number - currentUser?: { - id: string - name?: string | null - email?: string | null - image?: string | null - companyId?: number | null - domain?: string | null - } - showTrigger?: boolean - onSuccess?: () => void -} - -export function InviteVendorsDialog({ - rfqId, - vendors, - currentUserId, - currentUser, - showTrigger = true, - onSuccess, - ...props -}: InviteVendorsDialogProps) { - const [files, setFiles] = React.useState<FileList | null>(null) - const isDesktop = useMediaQuery("(min-width: 640px)") - const [isSubmitting, setIsSubmitting] = React.useState(false) - - // 로컬 스키마와 폼 값을 사용하도록 수정 - const form = useForm<FormValues>({ - resolver: zodResolver(formSchema), - defaultValues: { - paymentTerms: "", - incoterms: "", - deliverySchedule: "", - notes: "", - }, - mode: "onChange", - }) - - // 폼 상태 감시 - const { formState } = form - const isValid = formState.isValid && - !!form.getValues("paymentTerms") && - !!form.getValues("incoterms") && - !!form.getValues("deliverySchedule") - - // 디버깅용 상태 트래킹 - React.useEffect(() => { - const subscription = form.watch((value) => { - // 폼 값이 변경될 때마다 실행되는 콜백 - console.log("Form values changed:", value); - }); - - return () => subscription.unsubscribe(); - }, [form]); - - async function onSubmit(data: FormValues) { - try { - setIsSubmitting(true) - - // 기본 FormData 생성 - const formData = new FormData() - - // rfqId 추가 - formData.append("rfqId", String(rfqId)) - - // 폼 데이터 추가 - Object.entries(data).forEach(([key, value]) => { - if (value !== undefined && value !== null) { - formData.append(key, String(value)) - } - }) - - // 현재 사용자 ID 추가 - if (currentUserId) { - formData.append("evaluatedBy", String(currentUserId)) - } - - // 협력업체 ID만 추가 (서버에서 연락처 정보를 조회) - vendors.forEach((vendor) => { - formData.append("vendorIds[]", String(vendor.vendorId)) - }) - - // 파일 추가 (있는 경우에만) - if (files && files.length > 0) { - for (let i = 0; i < files.length; i++) { - formData.append("files", files[i]) - } - } - - // 서버 액션 호출 - const response = await createCbeEvaluation(formData) - - if (response.error) { - toast.error(response.error) - return - } - - // 성공 처리 - toast.success(`${vendors.length}개 협력업체에 CBE 평가가 성공적으로 전송되었습니다!`) - form.reset() - setFiles(null) - props.onOpenChange?.(false) - onSuccess?.() - } catch (error) { - console.error(error) - toast.error("CBE 평가 생성 중 오류가 발생했습니다.") - } finally { - setIsSubmitting(false) - } - } - - function handleDialogOpenChange(nextOpen: boolean) { - if (!nextOpen) { - form.reset() - setFiles(null) - } - props.onOpenChange?.(nextOpen) - } - - // 필수 필드 라벨에 추가할 요소 - const RequiredLabel = ( - <span className="text-destructive ml-1 font-medium">*</span> - ) - - const formContent = ( - <Form {...form}> - <form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4"> - {/* 선택된 협력업체 정보 표시 */} - <div className="space-y-2"> - <FormLabel>선택된 협력업체 ({vendors.length})</FormLabel> - <ScrollArea className="h-20 border rounded-md p-2"> - <div className="flex flex-wrap gap-2"> - {vendors.map((vendor, index) => ( - <Badge key={index} variant="secondary" className="py-1"> - {vendor.vendorName || `협력업체 #${vendor.vendorCode}`} - </Badge> - ))} - </div> - </ScrollArea> - <FormDescription> - 선택된 모든 협력업체의 등록된 연락처에게 CBE 평가 알림이 전송됩니다. - </FormDescription> - </div> - - {/* 작성자 정보 (읽기 전용) */} - {currentUser && ( - <div className="border rounded-md p-3 space-y-2"> - <FormLabel>작성자</FormLabel> - <div className="flex items-center gap-3"> - {currentUser.image ? ( - <Avatar className="h-8 w-8"> - <AvatarImage src={currentUser.image} alt={currentUser.name || ""} /> - <AvatarFallback> - {currentUser.name?.charAt(0) || <User className="h-4 w-4" />} - </AvatarFallback> - </Avatar> - ) : ( - <Avatar className="h-8 w-8"> - <AvatarFallback> - {currentUser.name?.charAt(0) || <User className="h-4 w-4" />} - </AvatarFallback> - </Avatar> - )} - <div> - <p className="text-sm font-medium">{currentUser.name || "Unknown User"}</p> - <p className="text-xs text-muted-foreground">{currentUser.email || ""}</p> - </div> - </div> - </div> - )} - - {/* 결제 조건 - 필수 필드 */} - <FormField - control={form.control} - name="paymentTerms" - render={({ field }) => ( - <FormItem> - <FormLabel> - 결제 조건{RequiredLabel} - </FormLabel> - <FormControl> - <Input {...field} placeholder="예: Net 30" /> - </FormControl> - <FormMessage /> - </FormItem> - )} - /> - - {/* Incoterms - 필수 필드 */} - <FormField - control={form.control} - name="incoterms" - render={({ field }) => ( - <FormItem> - <FormLabel> - Incoterms{RequiredLabel} - </FormLabel> - <FormControl> - <Input {...field} placeholder="예: FOB, CIF" /> - </FormControl> - <FormMessage /> - </FormItem> - )} - /> - - {/* 배송 일정 - 필수 필드 */} - <FormField - control={form.control} - name="deliverySchedule" - render={({ field }) => ( - <FormItem> - <FormLabel> - 배송 일정{RequiredLabel} - </FormLabel> - <FormControl> - <Textarea - {...field} - placeholder="배송 일정 세부사항을 입력하세요" - rows={3} - /> - </FormControl> - <FormMessage /> - </FormItem> - )} - /> - - {/* 비고 - 선택적 필드 */} - <FormField - control={form.control} - name="notes" - render={({ field }) => ( - <FormItem> - <FormLabel>비고</FormLabel> - <FormControl> - <Textarea - {...field} - placeholder="추가 비고 사항을 입력하세요" - rows={3} - /> - </FormControl> - <FormMessage /> - </FormItem> - )} - /> - - {/* 파일 첨부 (옵션) */} - <div className="space-y-2"> - <FormLabel htmlFor="files">첨부 파일 (선택사항)</FormLabel> - <Input - id="files" - type="file" - multiple - onChange={(e) => setFiles(e.target.files)} - /> - {files && files.length > 0 && ( - <p className="text-sm text-muted-foreground"> - {files.length}개 파일이 첨부되었습니다 - </p> - )} - </div> - - {/* 필수 입력 항목 안내 */} - <div className="text-sm text-muted-foreground"> - <span className="text-destructive">*</span> 표시는 필수 입력 항목입니다. - </div> - - {/* 모바일에서는 Drawer 내부에서 버튼이 렌더링되므로 여기서는 숨김 */} - {isDesktop && ( - <DialogFooter className="gap-2 pt-4"> - <DialogClose asChild> - <Button - type="button" - variant="outline" - > - 취소 - </Button> - </DialogClose> - <Button - type="submit" - disabled={isSubmitting || !isValid} - > - {isSubmitting && ( - <Loader className="mr-2 size-4 animate-spin" aria-hidden="true" /> - )} - {vendors.length > 1 ? `${vendors.length}개 협력업체에 전송` : "전송"} - </Button> - </DialogFooter> - )} - </form> - </Form> - ) - - // Desktop Dialog - if (isDesktop) { - return ( - <Dialog {...props} onOpenChange={handleDialogOpenChange}> - {showTrigger ? ( - <DialogTrigger asChild> - <Button variant="outline" size="sm"> - <Send className="mr-2 size-4" aria-hidden="true" /> - CBE 평가 전송 ({vendors.length}) - </Button> - </DialogTrigger> - ) : null} - <DialogContent className="sm:max-w-[600px]"> - <DialogHeader> - <DialogTitle>CBE 평가 생성 및 전송</DialogTitle> - <DialogDescription> - 선택한 {vendors.length}개 협력업체에 대한 상업 입찰 평가를 생성하고 알림을 전송합니다. - </DialogDescription> - </DialogHeader> - - {formContent} - </DialogContent> - </Dialog> - ) - } - - // Mobile Drawer - return ( - <Drawer {...props} onOpenChange={handleDialogOpenChange}> - {showTrigger ? ( - <DrawerTrigger asChild> - <Button variant="outline" size="sm"> - <Send className="mr-2 size-4" aria-hidden="true" /> - CBE 평가 전송 ({vendors.length}) - </Button> - </DrawerTrigger> - ) : null} - <DrawerContent> - <DrawerHeader> - <DrawerTitle>CBE 평가 생성 및 전송</DrawerTitle> - <DrawerDescription> - 선택한 {vendors.length}개 협력업체에 대한 상업 입찰 평가를 생성하고 알림을 전송합니다. - </DrawerDescription> - </DrawerHeader> - - <div className="px-4"> - {formContent} - </div> - - <DrawerFooter className="gap-2 sm:space-x-0"> - <DrawerClose asChild> - <Button variant="outline">취소</Button> - </DrawerClose> - <Button - onClick={form.handleSubmit(onSubmit)} - disabled={isSubmitting || !isValid} - > - {isSubmitting && ( - <Loader className="mr-2 size-4 animate-spin" aria-hidden="true" /> - )} - {vendors.length > 1 ? `${vendors.length}개 협력업체에 전송` : "전송"} - </Button> - </DrawerFooter> - </DrawerContent> - </Drawer> - ) -}
\ No newline at end of file diff --git a/lib/rfqs-tech/cbe-table/vendor-contact-dialog.tsx b/lib/rfqs-tech/cbe-table/vendor-contact-dialog.tsx deleted file mode 100644 index 180db392..00000000 --- a/lib/rfqs-tech/cbe-table/vendor-contact-dialog.tsx +++ /dev/null @@ -1,71 +0,0 @@ -"use client" - -import * as React from "react" -import { - Dialog, - DialogContent, - DialogHeader, - DialogTitle, -} from "@/components/ui/dialog" -import { Badge } from "@/components/ui/badge" -import { VendorWithCbeFields } from "@/config/vendorCbeColumnsConfig" -import { VendorContactsTable } from "../tbe-table/vendor-contact/vendor-contact-table" - -interface VendorContactsDialogProps { - isOpen: boolean - onOpenChange: (open: boolean) => void - vendorId: number | null - vendor: VendorWithCbeFields | null -} - -export function VendorContactsDialog({ - isOpen, - onOpenChange, - vendorId, - vendor, -}: VendorContactsDialogProps) { - return ( - <Dialog open={isOpen} onOpenChange={onOpenChange}> - <DialogContent className="max-w-[90wv] sm:max-h-[80vh] overflow-auto" style={{maxWidth:1000, height:480}}> - <DialogHeader> - <div className="flex flex-col space-y-2"> - <DialogTitle>협력업체 연락처</DialogTitle> - {vendor && ( - <div className="flex flex-col space-y-1 mt-2"> - <div className="text-sm text-muted-foreground"> - <span className="font-medium text-foreground">{vendor.vendorName}</span> - {vendor.vendorCode && ( - <span className="ml-2 text-xs text-muted-foreground">({vendor.vendorCode})</span> - )} - </div> - <div className="flex items-center"> - {vendor.vendorStatus && ( - <Badge variant="outline" className="mr-2"> - {vendor.vendorStatus} - </Badge> - )} - {vendor.commercialResponseStatus && ( - <Badge - variant={ - vendor.commercialResponseStatus === "INVITED" ? "default" : - vendor.commercialResponseStatus === "DECLINED" ? "destructive" : - vendor.commercialResponseStatus === "ACCEPTED" ? "secondary" : "outline" - } - > - {vendor.commercialResponseStatus} - </Badge> - )} - </div> - </div> - )} - </div> - </DialogHeader> - {vendorId && ( - <div className="py-4"> - <VendorContactsTable vendorId={vendorId} /> - </div> - )} - </DialogContent> - </Dialog> - ) -}
\ No newline at end of file diff --git a/lib/rfqs-tech/repository.ts b/lib/rfqs-tech/repository.ts deleted file mode 100644 index 6223e97b..00000000 --- a/lib/rfqs-tech/repository.ts +++ /dev/null @@ -1,222 +0,0 @@ -// src/lib/tasks/repository.ts -import db from "@/db/db"; -import { rfqItems, rfqs, rfqsView, type Rfq,VendorResponse, vendorResponses, RfqViewWithItems } from "@/db/schema/rfq"; -import { - eq, - inArray, - asc, - desc, - count, - gt, -} from "drizzle-orm"; -import { PgTransaction } from "drizzle-orm/pg-core"; -export type NewRfq = typeof rfqs.$inferInsert -export type NewRfqItem = typeof rfqItems.$inferInsert - -/** - * 단건/복수 조회 시 공통으로 사용 가능한 SELECT 함수 예시 - * - 트랜잭션(tx)을 받아서 사용하도록 구현 - */ -export async function selectRfqs( - tx: PgTransaction<any, any, any>, - params: { - where?: any; - orderBy?: (ReturnType<typeof asc> | ReturnType<typeof desc>)[]; - offset?: number; - limit?: number; - } -) { - const { where, orderBy, offset = 0, limit = 10 } = params; - - return tx - .select({ - rfqId: rfqsView.id, - id: rfqsView.id, - rfqCode: rfqsView.rfqCode, - description: rfqsView.description, - projectCode: rfqsView.projectCode, - projectName: rfqsView.projectName, - dueDate: rfqsView.dueDate, - status: rfqsView.status, - // createdBy → user 이메일 - createdBy: rfqsView.createdBy, // still the numeric user ID - createdByEmail: rfqsView.userEmail, // string - - createdAt: rfqsView.createdAt, - updatedAt: rfqsView.updatedAt, - // ==================== - // 1) itemCount via subselect - // ==================== - itemCount:rfqsView.itemCount, - attachCount: rfqsView.attachmentCount, - - // user info - // userId: users.id, - userEmail: rfqsView.userEmail, - userName: rfqsView.userName, - }) - .from(rfqsView) - .where(where ?? undefined) - .orderBy(...(orderBy ?? [])) - .offset(offset) - .limit(limit); -} -/** 총 개수 count */ -export async function countRfqs( - tx: PgTransaction<any, any, any>, - where?: any -) { - const res = await tx.select({ count: count() }).from(rfqsView).where(where); - return res[0]?.count ?? 0; -} - -/** 단건 Insert 예시 */ -export async function insertRfq( - tx: PgTransaction<any, any, any>, - data: NewRfq // DB와 동일한 insert 가능한 타입 -) { - // returning() 사용 시 배열로 돌아오므로 [0]만 리턴 - return tx - .insert(rfqs) - .values(data) - .returning({ id: rfqs.id, createdAt: rfqs.createdAt }); -} - -/** 복수 Insert 예시 */ -export async function insertRfqs( - tx: PgTransaction<any, any, any>, - data: Rfq[] -) { - return tx.insert(rfqs).values(data).onConflictDoNothing(); -} - -/** 단건 삭제 */ -export async function deleteRfqById( - tx: PgTransaction<any, any, any>, - rfqId: number -) { - return tx.delete(rfqs).where(eq(rfqs.id, rfqId)); -} - -/** 복수 삭제 */ -export async function deleteRfqsByIds( - tx: PgTransaction<any, any, any>, - ids: number[] -) { - return tx.delete(rfqs).where(inArray(rfqs.id, ids)); -} - -/** 전체 삭제 */ -export async function deleteAllRfqs( - tx: PgTransaction<any, any, any>, -) { - return tx.delete(rfqs); -} - -/** 단건 업데이트 */ -export async function updateRfq( - tx: PgTransaction<any, any, any>, - rfqId: number, - data: Partial<Rfq> -) { - return tx - .update(rfqs) - .set(data) - .where(eq(rfqs.id, rfqId)) - .returning({ status: rfqs.status }); -} - -// /** 복수 업데이트 */ -export async function updateRfqs( - tx: PgTransaction<any, any, any>, - ids: number[], - data: Partial<Rfq> -) { - return tx - .update(rfqs) - .set(data) - .where(inArray(rfqs.id, ids)) - .returning({ status: rfqs.status, dueDate: rfqs.dueDate }); -} - - -// 모든 task 조회 -export const getAllRfqs = async (): Promise<Rfq[]> => { - const datas = await db.select().from(rfqs).execute(); - return datas -}; - - -export async function groupByStatus( - tx: PgTransaction<any, any, any>, -) { - return tx - .select({ - status: rfqs.status, - count: count(), - }) - .from(rfqs) - .groupBy(rfqs.status) - .having(gt(count(), 0)); -} - -export async function insertRfqItem( - tx: PgTransaction<any, any, any>, - data: NewRfqItem -) { - return tx.insert(rfqItems).values(data).returning(); -} - -export const getRfqById = async (id: number): Promise<RfqViewWithItems | null> => { - // 1) RFQ 단건 조회 - const rfqsRes = await db - .select() - .from(rfqsView) - .where(eq(rfqsView.id, id)) - .limit(1); - - if (rfqsRes.length === 0) return null; - const rfqRow = rfqsRes[0]; - - // 2) 해당 RFQ 아이템 목록 조회 - const itemsRes = await db - .select() - .from(rfqItems) - .where(eq(rfqItems.rfqId, id)); - - // itemsRes: RfqItem[] - - // 3) RfqWithItems 형태로 반환 - const result: RfqViewWithItems = { - ...rfqRow, - lines: itemsRes, - }; - - return result; -}; - -/** 단건 업데이트 */ -export async function updateRfqVendor( - tx: PgTransaction<any, any, any>, - rfqVendorId: number, - data: Partial<VendorResponse> -) { - return tx - .update(vendorResponses) - .set(data) - .where(eq(vendorResponses.id, rfqVendorId)) - .returning({ status: vendorResponses.responseStatus }); -} - -/** 복수 업데이트 */ -export async function updateRfqVendors( - tx: PgTransaction<any, any, any>, - ids: number[], - data: Partial<VendorResponse> -) { - return tx - .update(vendorResponses) - .set(data) - .where(inArray(vendorResponses.id, ids)) - .returning({ status: vendorResponses.responseStatus }); -} diff --git a/lib/rfqs-tech/service.ts b/lib/rfqs-tech/service.ts deleted file mode 100644 index 6989188b..00000000 --- a/lib/rfqs-tech/service.ts +++ /dev/null @@ -1,3678 +0,0 @@ -// src/lib/tasks/service.ts -"use server"; // Next.js 서버 액션에서 직접 import하려면 (선택) - -import { revalidatePath, revalidateTag } from "next/cache"; -import db from "@/db/db"; - -import { filterColumns } from "@/lib/filter-columns"; -import { getErrorMessage } from "@/lib/handle-error"; - -import { GetRfqsSchema, CreateRfqSchema, UpdateRfqSchema, CreateRfqItemSchema, GetMatchedVendorsSchema, UpdateRfqVendorSchema, GetTBESchema, GetCBESchema, createCbeEvaluationSchema } from "./validations"; -import { asc, desc, ilike, inArray, and, or, sql, eq, isNull, ne, isNotNull, count } from "drizzle-orm"; -import path from "path"; -import { join } from 'path' - -import { vendorResponses, vendorResponsesView, Rfq, rfqs, rfqAttachments, rfqItems, rfqComments, rfqEvaluations, vendorRfqView, vendorTbeView, rfqsView, vendorResponseAttachments, vendorTechnicalResponses, cbeEvaluations, vendorCommercialResponses, vendorResponseCBEView, RfqViewWithItems } from "@/db/schema/rfq"; -import { countRfqs, deleteRfqById, deleteRfqsByIds, getRfqById, groupByStatus, insertRfq, insertRfqItem, selectRfqs, updateRfq, updateRfqs, updateRfqVendor } from "./repository"; -import logger from '@/lib/logger'; -import { vendorContacts, vendorPossibleItems, vendors } from "@/db/schema/vendors"; -import { sendEmail } from "../mail/sendEmail"; -import { biddingProjects, projects } from "@/db/schema/projects"; -import * as z from "zod" -import { users } from "@/db/schema/users"; -import { headers } from 'next/headers'; - -// DRM 복호화 관련 유틸 import -import { decryptWithServerAction } from "@/components/drm/drmUtils"; -import { deleteFile, saveDRMFile, saveFile } from "../file-stroage"; - -interface InviteVendorsInput { - rfqId: number - vendorIds: number[] -} - -/* ----------------------------------------------------- - 1) 조회 관련 ------------------------------------------------------ */ - -/** - * 복잡한 조건으로 Rfq 목록을 조회 (+ pagination) 하고, - * 총 개수에 따라 pageCount를 계산해서 리턴. - */ -export async function getRfqs(input: GetRfqsSchema) { - try { - const offset = (input.page - 1) * input.perPage; - // const advancedTable = input.flags.includes("advancedTable"); - const advancedTable = true; - - // advancedTable 모드면 filterColumns()로 where 절 구성 - const advancedWhere = filterColumns({ - table: rfqsView, - filters: input.filters, - joinOperator: input.joinOperator, - }); - - - let globalWhere - if (input.search) { - const s = `%${input.search}%` - globalWhere = or(ilike(rfqsView.rfqCode, s), ilike(rfqsView.projectCode, s) - , ilike(rfqsView.projectName, s), ilike(rfqsView.dueDate, s), ilike(rfqsView.status, s) - ) - // 필요시 여러 칼럼 OR조건 (status, priority, etc) - } - - const whereConditions = []; - if (advancedWhere) whereConditions.push(advancedWhere); - if (globalWhere) whereConditions.push(globalWhere); - - // 조건이 있을 때만 and() 사용 - const finalWhere = whereConditions.length > 0 - ? and(...whereConditions) - : undefined; - - const orderBy = - input.sort.length > 0 - ? input.sort.map((item) => - item.desc ? desc(rfqsView[item.id]) : asc(rfqsView[item.id]) - ) - : [asc(rfqsView.createdAt)]; - - // 트랜잭션 내부에서 Repository 호출 - const { data, total } = await db.transaction(async (tx) => { - const data = await selectRfqs(tx, { - where: finalWhere, - orderBy, - offset, - limit: input.perPage, - }); - - const total = await countRfqs(tx, finalWhere); - return { data, total }; - }); - - - const pageCount = Math.ceil(total / input.perPage); - - - return { data, pageCount }; - } catch (err) { - console.error("getRfqs 에러:", err); // 자세한 에러 로깅 - - // 에러 발생 시 디폴트 - return { data: [], pageCount: 0 }; - } -} - -/** Status별 개수 */ -export async function getRfqStatusCounts() { - try { - const initial: Record<Rfq["status"], number> = { - DRAFT: 0, - PUBLISHED: 0, - EVALUATION: 0, - AWARDED: 0, - }; - - const result = await db.transaction(async (tx) => { - const rows = await groupByStatus(tx); - return rows.reduce<Record<Rfq["status"], number>>((acc, { status, count }) => { - acc[status] = count; - return acc; - }, initial); - }); - - return result; - } catch { - return {} as Record<Rfq["status"], number>; - } -} - - - -/* ----------------------------------------------------- - 2) 생성(Create) ------------------------------------------------------ */ - -/** - * Rfq 생성 후, (가장 오래된 Rfq 1개) 삭제로 - * 전체 Rfq 개수를 고정 - */ -export async function createRfq(input: CreateRfqSchema) { - try { - await db.transaction(async (tx) => { - await insertRfq(tx, { - rfqCode: input.rfqCode, - projectId: input.projectId || null, - description: input.description, - dueDate: input.dueDate, - status: input.status, - createdBy: input.createdBy, - }); - }); - - return { data: null, error: null }; - } catch (err) { - return { data: null, error: getErrorMessage(err) }; - } -} - -/* ----------------------------------------------------- - 3) 업데이트 ------------------------------------------------------ */ - -/** 단건 업데이트 */ -export async function modifyRfq(input: UpdateRfqSchema & { id: number }) { - try { - await db.transaction(async (tx) => { - await updateRfq(tx, input.id, { - rfqCode: input.rfqCode, - projectId: input.projectId || null, - dueDate: input.dueDate, - status: input.status as "DRAFT" | "PUBLISHED" | "EVALUATION" | "AWARDED", - createdBy: input.createdBy, - }); - }); - - - return { data: null, error: null }; - } catch (err) { - return { data: null, error: getErrorMessage(err) }; - } -} - -export async function modifyRfqs(input: { - ids: number[]; - status?: Rfq["status"]; - dueDate?: Date -}) { - try { - await db.transaction(async (tx) => { - await updateRfqs(tx, input.ids, { - status: input.status, - dueDate: input.dueDate, - }); - }); - - - - return { data: null, error: null }; - } catch (err) { - return { data: null, error: getErrorMessage(err) }; - } -} - - -/* ----------------------------------------------------- - 4) 삭제 ------------------------------------------------------ */ - -/** 단건 삭제 */ -export async function removeRfq(input: { id: number }) { - try { - await db.transaction(async (tx) => { - // 삭제 - await deleteRfqById(tx, input.id); - // 바로 새 Rfq 생성 - }); - - - - return { data: null, error: null }; - } catch (err) { - return { data: null, error: getErrorMessage(err) }; - } -} - -/** 복수 삭제 */ -export async function removeRfqs(input: { ids: number[] }) { - try { - await db.transaction(async (tx) => { - // 삭제 - await deleteRfqsByIds(tx, input.ids); - }); - - - - return { data: null, error: null }; - } catch (err) { - return { data: null, error: getErrorMessage(err) }; - } -} - - -/** - * RFQ 아이템 삭제 함수 - */ -export async function deleteRfqItem(input: { id: number, rfqId: number }) { - try { - // 삭제 작업 수행 - await db - .delete(rfqItems) - .where( - and( - eq(rfqItems.id, input.id), - eq(rfqItems.rfqId, input.rfqId) - ) - ); - - return { data: null, error: null }; - } catch (err) { - console.error("Error in deleteRfqItem:", err); - return { data: null, error: getErrorMessage(err) }; - } -} - -// createRfqItem 함수 수정 (id 파라미터 추가) -export async function createRfqItem(input: CreateRfqItemSchema & { id?: number }) { - try { - // DB 트랜잭션 - await db.transaction(async (tx) => { - // id가 전달되었으면 해당 id로 업데이트, 그렇지 않으면 기존 로직대로 진행 - if (input.id) { - // 기존 아이템 업데이트 - await tx - .update(rfqItems) - .set({ - description: input.description ?? null, - quantity: input.quantity ?? 1, - uom: input.uom ?? "", - updatedAt: new Date(), - }) - .where(eq(rfqItems.id, input.id)); - - } else { - // 기존 로직: 같은 itemCode로 이미 존재하는지 확인 후 업데이트/생성 - const existingItems = await tx - .select() - .from(rfqItems) - .where( - and( - eq(rfqItems.rfqId, input.rfqId), - eq(rfqItems.itemCode, input.itemCode) - ) - ); - - if (existingItems.length > 0) { - // 이미 존재하는 경우 업데이트 - const existingItem = existingItems[0]; - await tx - .update(rfqItems) - .set({ - description: input.description ?? null, - quantity: input.quantity ?? 1, - uom: input.uom ?? "", - updatedAt: new Date(), - }) - .where(eq(rfqItems.id, existingItem.id)); - - console.log(`Updated existing RFQ item: ${existingItem.id} for RFQ ${input.rfqId}, Item ${input.itemCode}`); - } else { - // 존재하지 않는 경우 새로 생성 - await insertRfqItem(tx, { - rfqId: input.rfqId, - itemCode: input.itemCode, - description: input.description ?? null, - quantity: input.quantity ?? 1, - uom: input.uom ?? "", - }); - - console.log(`Created new RFQ item for RFQ ${input.rfqId}, Item ${input.itemCode}`); - } - } - }); - - return { data: null, error: null }; - } catch (err) { - console.error("Error in createRfqItem:", err); - return { data: null, error: getErrorMessage(err) }; - } -} -/** - * 서버 액션: 파일 첨부/삭제 처리 - * @param rfqId RFQ ID - * @param removedExistingIds 기존 첨부 중 삭제된 record ID 배열 - * @param newFiles 새로 업로드된 파일 (File[]) - Next.js server action에서 - * @param vendorId (optional) 업로더가 vendor인지 구분 - */ -export async function processRfqAttachments(args: { - rfqId: number; - removedExistingIds?: number[]; - newFiles?: File[]; - vendorId?: number | null; -}) { - const { rfqId, removedExistingIds = [], newFiles = [], vendorId = null } = args; - - try { - // 1) 삭제된 기존 첨부: DB + 파일시스템에서 제거 - if (removedExistingIds.length > 0) { - // 1-1) DB에서 filePath 조회 - const rows = await db - .select({ - id: rfqAttachments.id, - filePath: rfqAttachments.filePath - }) - .from(rfqAttachments) - .where(inArray(rfqAttachments.id, removedExistingIds)); - - // 1-2) DB 삭제 - await db - .delete(rfqAttachments) - .where(inArray(rfqAttachments.id, removedExistingIds)); - - // 1-3) 파일 삭제 - for (const row of rows) { - await deleteFile(row.filePath) - } - } - - // 2) 새 파일 업로드 - if (newFiles.length > 0) { - for (const file of newFiles) { - const saveResult = await saveDRMFile(file, decryptWithServerAction, `rfqTech/${rfqId}`) - - // 2-4) DB Insert - await db.insert(rfqAttachments).values({ - rfqId, - vendorId, - fileName: file.name, - filePath: saveResult.publicPath!, - // (Windows 경로 대비) - }); - } - } - - const [countRow] = await db - .select({ cnt: sql<number>`count(*)`.as("cnt") }) - .from(rfqAttachments) - .where(eq(rfqAttachments.rfqId, rfqId)); - - const newCount = countRow?.cnt ?? 0; - - return { ok: true, updatedItemCount: newCount }; - } catch (error) { - console.error("processRfqAttachments error:", error); - return { ok: false, error: String(error) }; - } -} - - - -export async function fetchRfqAttachments(rfqId: number) { - // DB select - const rows = await db - .select() - .from(rfqAttachments) - .where(eq(rfqAttachments.rfqId, rfqId)) - - // rows: { id, fileName, filePath, createdAt, vendorId, ... } - // 필요 없는 필드는 omit하거나 transform 가능 - return rows.map((row) => ({ - id: row.id, - fileName: row.fileName, - filePath: row.filePath, - createdAt: row.createdAt, // or string - vendorId: row.vendorId, - size: undefined, // size를 DB에 저장하지 않았다면 - })) -} - -export async function fetchRfqItems(rfqId: number) { - // DB select - const rows = await db - .select() - .from(rfqItems) - .where(eq(rfqItems.rfqId, rfqId)) - - // rows: { id, fileName, filePath, createdAt, vendorId, ... } - // 필요 없는 필드는 omit하거나 transform 가능 - return rows.map((row) => ({ - // id: row.id, - itemCode: row.itemCode, - description: row.description, - quantity: row.quantity, - uom: row.uom, - })) -} - -export const findRfqById = async (id: number): Promise<RfqViewWithItems | null> => { - try { - logger.info({ id }, 'Fetching user by ID'); - const rfq = await getRfqById(id); - if (!rfq) { - logger.warn({ id }, 'User not found'); - } else { - logger.debug({ rfq }, 'User fetched successfully'); - } - return rfq; - } catch (error) { - logger.error({ error }, 'Error fetching user by ID'); - throw new Error('Failed to fetch user'); - } -}; - -export async function getMatchedVendors(input: GetMatchedVendorsSchema, rfqId: number) { - try { - // ───────────────────────────────────────────────────── - // 1) rfq_items에서 distinct itemCode - // ───────────────────────────────────────────────────── - const itemRows = await db - .select({ code: rfqItems.itemCode }) - .from(rfqItems) - .where(eq(rfqItems.rfqId, rfqId)) - .groupBy(rfqItems.itemCode) - - const itemCodes = itemRows.map((r) => r.code) - const itemCount = itemCodes.length - if (itemCount === 0) { - return { data: [], pageCount: 0 } - } - - // ───────────────────────────────────────────────────── - // 2) vendorPossibleItems에서 모든 itemCodes를 보유한 vendor - // ───────────────────────────────────────────────────── - const inList = itemCodes.map((c) => `'${c}'`).join(",") - const sqlVendorIds = await db.execute( - sql` - SELECT vpi.vendor_id AS "vendorId" - FROM ${vendorPossibleItems} vpi - WHERE vpi.item_code IN (${sql.raw(inList)}) - GROUP BY vpi.vendor_id - HAVING COUNT(DISTINCT vpi.item_code) = ${itemCount} - ` - ) - const vendorIdList = sqlVendorIds.rows.map((row: any) => +row.vendorId) - if (vendorIdList.length === 0) { - return { data: [], pageCount: 0 } - } - - // ───────────────────────────────────────────────────── - // 3) 필터/검색/정렬 - // ───────────────────────────────────────────────────── - const offset = ((input.page ?? 1) - 1) * (input.perPage ?? 10) - const limit = input.perPage ?? 10 - - // (가) 커스텀 필터 - // 여기서는 "뷰(vendorRfqView)"의 컬럼들에 대해 필터합니다. - const advancedWhere = filterColumns({ - // 테이블이 아니라 "뷰"를 넘길 수도 있고, - // 혹은 columns 객체(연결된 모든 컬럼)로 넘겨도 됩니다. - table: vendorRfqView, - filters: input.filters ?? [], - joinOperator: input.joinOperator ?? "and", - }) - - // (나) 글로벌 검색 - let globalWhere - if (input.search) { - const s = `%${input.search}%` - globalWhere = or( - sql`${vendorRfqView.vendorName} ILIKE ${s}`, - sql`${vendorRfqView.vendorCode} ILIKE ${s}`, - sql`${vendorRfqView.email} ILIKE ${s}` - ) - } - - // (다) 최종 where - // vendorId가 vendorIdList 내에 있어야 하고, - // 특정 rfqId(뷰에 담긴 값)도 일치해야 함. - const finalWhere = and( - inArray(vendorRfqView.vendorId, vendorIdList), - // 아래 라인은 rfq에 초대된 벤더만 필터링하는 조건으로 추정되지만 - // rfq 를 진행하기 전에도 벤더를 보여줘야 하므로 주석처리하겠습니다 - // eq(vendorRfqView.rfqId, rfqId), - advancedWhere, - globalWhere - ) - - // (라) 정렬 - const orderBy = input.sort?.length - ? input.sort.map((s) => { - // "column id" -> vendorRfqView.* 중 하나 - const col = (vendorRfqView as any)[s.id] - return s.desc ? desc(col) : asc(col) - }) - : [asc(vendorRfqView.vendorId)] - - // ───────────────────────────────────────────────────── - // 4) View에서 데이터 SELECT - // ───────────────────────────────────────────────────── - const [rows, total] = await db.transaction(async (tx) => { - const data = await tx - .select({ - id: vendorRfqView.vendorId, - vendorID: vendorRfqView.vendorId, - vendorName: vendorRfqView.vendorName, - vendorCode: vendorRfqView.vendorCode, - address: vendorRfqView.address, - country: vendorRfqView.country, - email: vendorRfqView.email, - website: vendorRfqView.website, - vendorStatus: vendorRfqView.vendorStatus, - // rfqVendorStatus와 rfqVendorUpdated는 나중에 정확한 데이터로 교체할 예정 - rfqVendorStatus: vendorRfqView.rfqVendorStatus, - rfqVendorUpdated: vendorRfqView.rfqVendorUpdated, - }) - .from(vendorRfqView) - .where(finalWhere) - .orderBy(...orderBy) - .offset(offset) - .limit(limit) - - // 중복 제거된 데이터 생성 - const distinctData = Array.from( - new Map(data.map(row => [row.id, row])).values() - ) - - // 중복 제거된 총 개수 계산 - const [{ count }] = await tx - .select({ count: sql<number>`count(DISTINCT ${vendorRfqView.vendorId})`.as("count") }) - .from(vendorRfqView) - .where(finalWhere) - - return [distinctData, Number(count)] - }) - - - // ───────────────────────────────────────────────────── - // 4-1) 정확한 rfqVendorStatus와 rfqVendorUpdated 조회 - // ───────────────────────────────────────────────────── - const distinctVendorIds = [...new Set(rows.map((r) => r.id))] - - // vendorResponses 테이블에서 정확한 상태와 업데이트 시간 조회 - const vendorStatuses = await db - .select({ - vendorId: vendorResponses.vendorId, - status: vendorResponses.responseStatus, - updatedAt: vendorResponses.updatedAt - }) - .from(vendorResponses) - .where( - and( - inArray(vendorResponses.vendorId, distinctVendorIds), - eq(vendorResponses.rfqId, rfqId) - ) - ) - - // vendorId별 상태정보 맵 생성 - const statusMap = new Map<number, { status: string, updatedAt: Date }>() - for (const vs of vendorStatuses) { - statusMap.set(vs.vendorId, { - status: vs.status, - updatedAt: vs.updatedAt - }) - } - - // 정확한 상태 정보로 업데이트된 rows 생성 - const updatedRows = rows.map(row => ({ - ...row, - rfqVendorStatus: statusMap.get(row.id)?.status || null, - rfqVendorUpdated: statusMap.get(row.id)?.updatedAt || null - })) - - // ───────────────────────────────────────────────────── - // 5) 코멘트 조회: 기존과 동일 - // ───────────────────────────────────────────────────── - console.log("distinctVendorIds", distinctVendorIds) - const commAll = await db - .select() - .from(rfqComments) - .where( - and( - inArray(rfqComments.vendorId, distinctVendorIds), - eq(rfqComments.rfqId, rfqId), - isNull(rfqComments.evaluationId), - isNull(rfqComments.cbeId) - ) - ) - - const commByVendorId = new Map<number, any[]>() - // 먼저 모든 사용자 ID를 수집 - const userIds = new Set(commAll.map(c => c.commentedBy)); - const userIdsArray = Array.from(userIds); - - // Drizzle의 select 메서드를 사용하여 사용자 정보를 가져옴 - const usersData = await db - .select({ - id: users.id, - email: users.email, - }) - .from(users) - .where(inArray(users.id, userIdsArray)); - - // 사용자 ID를 키로 하는 맵 생성 - const userMap = new Map(); - for (const user of usersData) { - userMap.set(user.id, user); - } - - // 댓글 정보를 협력업체 ID별로 그룹화하고, 사용자 이메일 추가 - for (const c of commAll) { - const vid = c.vendorId! - if (!commByVendorId.has(vid)) { - commByVendorId.set(vid, []) - } - - // 사용자 정보 가져오기 - const user = userMap.get(c.commentedBy); - const userEmail = user ? user.email : 'unknown@example.com'; // 사용자를 찾지 못한 경우 기본값 설정 - - commByVendorId.get(vid)!.push({ - id: c.id, - commentText: c.commentText, - vendorId: c.vendorId, - evaluationId: c.evaluationId, - createdAt: c.createdAt, - commentedBy: c.commentedBy, - commentedByEmail: userEmail, // 이메일 추가 - }) - } - // ───────────────────────────────────────────────────── - // 6) rows에 comments 병합 - // ───────────────────────────────────────────────────── - const final = updatedRows.map((row) => ({ - ...row, - comments: commByVendorId.get(row.id) ?? [], - })) - - // ───────────────────────────────────────────────────── - // 7) 반환 - // ───────────────────────────────────────────────────── - const pageCount = Math.ceil(total / limit) - return { data: final, pageCount } - } catch (err) { - return { data: null, error: getErrorMessage(err) }; - } -} - -export async function inviteVendors(input: InviteVendorsInput) { - try { - const { rfqId, vendorIds } = input - if (!rfqId || !Array.isArray(vendorIds) || vendorIds.length === 0) { - throw new Error("Invalid input") - } - - const headersList = await headers(); - const host = headersList.get('host') || 'localhost:3000'; - - // DB 데이터 준비 및 첨부파일 처리를 위한 트랜잭션 - const rfqData = await db.transaction(async (tx) => { - // 2-A) RFQ 기본 정보 조회 - const [rfqRow] = await tx - .select({ - rfqCode: rfqsView.rfqCode, - description: rfqsView.description, - projectCode: rfqsView.projectCode, - projectName: rfqsView.projectName, - dueDate: rfqsView.dueDate, - createdBy: rfqsView.createdBy, - }) - .from(rfqsView) - .where(eq(rfqsView.id, rfqId)) - - if (!rfqRow) { - throw new Error(`RFQ #${rfqId} not found`) - } - - // 2-B) 아이템 목록 조회 - const items = await tx - .select({ - itemCode: rfqItems.itemCode, - description: rfqItems.description, - quantity: rfqItems.quantity, - uom: rfqItems.uom, - }) - .from(rfqItems) - .where(eq(rfqItems.rfqId, rfqId)) - - // 2-C) 첨부파일 목록 조회 - const attachRows = await tx - .select({ - id: rfqAttachments.id, - fileName: rfqAttachments.fileName, - filePath: rfqAttachments.filePath, - }) - .from(rfqAttachments) - .where( - and( - eq(rfqAttachments.rfqId, rfqId), - isNull(rfqAttachments.vendorId), - isNull(rfqAttachments.evaluationId) - ) - ) - - const vendorRows = await tx - .select({ id: vendors.id, email: vendors.email }) - .from(vendors) - .where(inArray(vendors.id, vendorIds)) - - // NodeMailer attachments 형식 맞추기 - const attachments = [] - for (const att of attachRows) { - const absolutePath = path.join(process.cwd(), "public", att.filePath.replace(/^\/+/, "")) - attachments.push({ - path: absolutePath, - filename: att.fileName, - }) - } - - return { rfqRow, items, vendorRows, attachments } - }) - - const { rfqRow, items, vendorRows, attachments } = rfqData - const loginUrl = `http://${host}/en/partners/rfq-tech` - - // 이메일 전송 오류를 기록할 배열 - const emailErrors = [] - - // 각 벤더에 대해 처리 - for (const v of vendorRows) { - if (!v.email) { - continue // 이메일 없는 협력업체 무시 - } - - try { - // DB 업데이트: 각 협력업체 상태 별도 트랜잭션 - await db.transaction(async (tx) => { - // rfq_vendors upsert - const existing = await tx - .select() - .from(vendorResponses) - .where(and(eq(vendorResponses.rfqId, rfqId), eq(vendorResponses.vendorId, v.id))) - - if (existing.length > 0) { - await tx - .update(vendorResponses) - .set({ - responseStatus: "INVITED", - updatedAt: new Date(), - }) - .where(eq(vendorResponses.id, existing[0].id)) - } else { - await tx.insert(vendorResponses).values({ - rfqId, - vendorId: v.id, - responseStatus: "INVITED", - }) - } - }) - - // 이메일 발송 (트랜잭션 외부) - await sendEmail({ - to: v.email, - subject: `[RFQ ${rfqRow.rfqCode}] You are invited from Samgsung Heavy Industries!`, - template: "rfq-invite", - context: { - language: "en", - rfqId, - vendorId: v.id, - rfqCode: rfqRow.rfqCode, - projectCode: rfqRow.projectCode, - projectName: rfqRow.projectName, - dueDate: rfqRow.dueDate, - description: rfqRow.description, - items: items.map((it) => ({ - itemCode: it.itemCode, - description: it.description, - quantity: it.quantity, - uom: it.uom, - })), - loginUrl - }, - attachments, - }) - } catch (err) { - // 개별 협력업체 처리 실패 로깅 - console.error(`Failed to process vendor ${v.id}: ${getErrorMessage(err)}`) - emailErrors.push({ vendorId: v.id, error: getErrorMessage(err) }) - // 계속 진행 (다른 협력업체 처리) - } - } - - // 최종적으로 RFQ 상태 업데이트 (별도 트랜잭션) - try { - await db.transaction(async (tx) => { - await tx - .update(rfqs) - .set({ - status: "PUBLISHED", - updatedAt: new Date(), - }) - .where(eq(rfqs.id, rfqId)) - - console.log(`Updated RFQ #${rfqId} status to PUBLISHED`) - }) - // 이메일 오류가 있었는지 확인 - if (emailErrors.length > 0) { - return { - error: `일부 벤더에게 이메일 발송 실패 (${emailErrors.length}/${vendorRows.length}), RFQ 상태는 업데이트됨`, - emailErrors - } - } - - return { error: null } - } catch (err) { - return { error: `RFQ 상태 업데이트 실패: ${getErrorMessage(err)}` } - } - } catch (err) { - return { error: getErrorMessage(err) } - } -} - - -/** - * TBE용 평가 데이터 목록 조회 - */ -export async function getTBE(input: GetTBESchema, rfqId: number) { - try { - // 1) 페이징 - const offset = ((input.page ?? 1) - 1) * (input.perPage ?? 10) - const limit = input.perPage ?? 10 - - // 2) 고급 필터 - const advancedWhere = filterColumns({ - table: vendorTbeView, - filters: input.filters ?? [], - joinOperator: input.joinOperator ?? "and", - }) - - // 3) 글로벌 검색 - let globalWhere - if (input.search) { - const s = `%${input.search}%` - globalWhere = or( - sql`${vendorTbeView.vendorName} ILIKE ${s}`, - sql`${vendorTbeView.vendorCode} ILIKE ${s}`, - sql`${vendorTbeView.email} ILIKE ${s}` - ) - } - - // 4) REJECTED 아니거나 NULL - const notRejected = or( - ne(vendorTbeView.rfqVendorStatus, "REJECTED"), - isNull(vendorTbeView.rfqVendorStatus) - ) - - // 5) finalWhere - const finalWhere = and( - eq(vendorTbeView.rfqId, rfqId), - advancedWhere, - globalWhere - ) - - // 6) 정렬 - const orderBy = input.sort?.length - ? input.sort.map((s) => { - const col = (vendorTbeView as any)[s.id] - return s.desc ? desc(col) : asc(col) - }) - : [asc(vendorTbeView.vendorId)] - - // 7) 메인 SELECT - const [rows, total] = await db.transaction(async (tx) => { - const data = await tx - .select({ - // 원하는 컬럼들 - id: vendorTbeView.vendorId, - tbeId: vendorTbeView.tbeId, - vendorId: vendorTbeView.vendorId, - vendorName: vendorTbeView.vendorName, - vendorCode: vendorTbeView.vendorCode, - address: vendorTbeView.address, - country: vendorTbeView.country, - email: vendorTbeView.email, - website: vendorTbeView.website, - vendorStatus: vendorTbeView.vendorStatus, - - rfqId: vendorTbeView.rfqId, - rfqCode: vendorTbeView.rfqCode, - projectCode: vendorTbeView.projectCode, - projectName: vendorTbeView.projectName, - description: vendorTbeView.description, - dueDate: vendorTbeView.dueDate, - - rfqVendorStatus: vendorTbeView.rfqVendorStatus, - rfqVendorUpdated: vendorTbeView.rfqVendorUpdated, - - tbeResult: vendorTbeView.tbeResult, - tbeNote: vendorTbeView.tbeNote, - tbeUpdated: vendorTbeView.tbeUpdated, - - technicalResponseId:vendorTbeView.technicalResponseId, - technicalResponseStatus:vendorTbeView.technicalResponseStatus, - technicalSummary:vendorTbeView.technicalSummary, - technicalNotes:vendorTbeView.technicalNotes, - technicalUpdated:vendorTbeView.technicalUpdated, - }) - .from(vendorTbeView) - .where(finalWhere) - .orderBy(...orderBy) - .offset(offset) - .limit(limit) - - const [{ count }] = await tx - .select({ count: sql<number>`count(*)`.as("count") }) - .from(vendorTbeView) - .where(finalWhere) - - return [data, Number(count)] - }) - - if (!rows.length) { - return { data: [], pageCount: 0 } - } - - // 8) Comments 조회 - const distinctVendorIds = [...new Set(rows.map((r) => r.vendorId))] - - const commAll = await db - .select({ - id: rfqComments.id, - commentText: rfqComments.commentText, - vendorId: rfqComments.vendorId, - evaluationId: rfqComments.evaluationId, - createdAt: rfqComments.createdAt, - commentedBy: rfqComments.commentedBy, - evalType: rfqEvaluations.evalType, - }) - .from(rfqComments) - .innerJoin( - rfqEvaluations, - and( - eq(rfqEvaluations.id, rfqComments.evaluationId), - eq(rfqEvaluations.evalType, "TBE") - ) - ) - .where( - and( - isNotNull(rfqComments.evaluationId), - eq(rfqComments.rfqId, rfqId), - inArray(rfqComments.vendorId, distinctVendorIds) - ) - ) - - // 8-A) vendorId -> comments grouping - const commByVendorId = new Map<number, any[]>() - for (const c of commAll) { - const vid = c.vendorId! - if (!commByVendorId.has(vid)) { - commByVendorId.set(vid, []) - } - commByVendorId.get(vid)!.push({ - id: c.id, - commentText: c.commentText, - vendorId: c.vendorId, - evaluationId: c.evaluationId, - createdAt: c.createdAt, - commentedBy: c.commentedBy, - }) - } - - // 9) TBE 파일 조회 - vendorResponseAttachments로 대체 - // Step 1: Get vendorResponses for the rfqId and vendorIds - const responsesAll = await db - .select({ - id: vendorResponses.id, - vendorId: vendorResponses.vendorId - }) - .from(vendorResponses) - .where( - and( - eq(vendorResponses.rfqId, rfqId), - inArray(vendorResponses.vendorId, distinctVendorIds) - ) - ); - - // Group responses by vendorId for later lookup - const responsesByVendorId = new Map<number, number[]>(); - for (const resp of responsesAll) { - if (!responsesByVendorId.has(resp.vendorId)) { - responsesByVendorId.set(resp.vendorId, []); - } - responsesByVendorId.get(resp.vendorId)!.push(resp.id); - } - - // Step 2: Get all responseIds - const allResponseIds = responsesAll.map(r => r.id); - - // Step 3: Get technicalResponses for these responseIds - const technicalResponsesAll = await db - .select({ - id: vendorTechnicalResponses.id, - responseId: vendorTechnicalResponses.responseId - }) - .from(vendorTechnicalResponses) - .where(inArray(vendorTechnicalResponses.responseId, allResponseIds)); - - // Create mapping from responseId to technicalResponseIds - const technicalResponseIdsByResponseId = new Map<number, number[]>(); - for (const tr of technicalResponsesAll) { - if (!technicalResponseIdsByResponseId.has(tr.responseId)) { - technicalResponseIdsByResponseId.set(tr.responseId, []); - } - technicalResponseIdsByResponseId.get(tr.responseId)!.push(tr.id); - } - - // Step 4: Get all technicalResponseIds - const allTechnicalResponseIds = technicalResponsesAll.map(tr => tr.id); - - // Step 5: Get attachments for these technicalResponseIds - const filesAll = await db - .select({ - id: vendorResponseAttachments.id, - fileName: vendorResponseAttachments.fileName, - filePath: vendorResponseAttachments.filePath, - technicalResponseId: vendorResponseAttachments.technicalResponseId, - fileType: vendorResponseAttachments.fileType, - attachmentType: vendorResponseAttachments.attachmentType, - description: vendorResponseAttachments.description, - uploadedAt: vendorResponseAttachments.uploadedAt, - uploadedBy: vendorResponseAttachments.uploadedBy - }) - .from(vendorResponseAttachments) - .where( - and( - inArray(vendorResponseAttachments.technicalResponseId, allTechnicalResponseIds), - isNotNull(vendorResponseAttachments.technicalResponseId) - ) - ); - - // Step 6: Create mapping from technicalResponseId to attachments - const filesByTechnicalResponseId = new Map<number, any[]>(); - for (const file of filesAll) { - // Skip if technicalResponseId is null (should never happen due to our filter above) - if (file.technicalResponseId === null) continue; - - if (!filesByTechnicalResponseId.has(file.technicalResponseId)) { - filesByTechnicalResponseId.set(file.technicalResponseId, []); - } - filesByTechnicalResponseId.get(file.technicalResponseId)!.push({ - id: file.id, - fileName: file.fileName, - filePath: file.filePath, - fileType: file.fileType, - attachmentType: file.attachmentType, - description: file.description, - uploadedAt: file.uploadedAt, - uploadedBy: file.uploadedBy - }); - } - - // Step 7: Create the final filesByVendorId map - const filesByVendorId = new Map<number, any[]>(); - for (const [vendorId, responseIds] of responsesByVendorId.entries()) { - filesByVendorId.set(vendorId, []); - - for (const responseId of responseIds) { - const technicalResponseIds = technicalResponseIdsByResponseId.get(responseId) || []; - - for (const technicalResponseId of technicalResponseIds) { - const files = filesByTechnicalResponseId.get(technicalResponseId) || []; - filesByVendorId.get(vendorId)!.push(...files); - } - } - } - - // 10) 최종 합치기 - const final = rows.map((row) => ({ - ...row, - dueDate: row.dueDate ? new Date(row.dueDate) : null, - comments: commByVendorId.get(row.vendorId) ?? [], - files: filesByVendorId.get(row.vendorId) ?? [], - })) - - const pageCount = Math.ceil(total / limit) - return { data: final, pageCount } - } catch (err) { - return { data: null, error: getErrorMessage(err) }; - } -} - -export async function getTBEforVendor(input: GetTBESchema, vendorId: number) { - - if (isNaN(vendorId) || vendorId === null || vendorId === undefined) { - throw new Error("유효하지 않은 vendorId: 숫자 값이 필요합니다"); - } - - try { - // 1) 페이징 - const offset = ((input.page ?? 1) - 1) * (input.perPage ?? 10) - const limit = input.perPage ?? 10 - - // 2) 고급 필터 - const advancedWhere = filterColumns({ - table: vendorTbeView, - filters: input.filters ?? [], - joinOperator: input.joinOperator ?? "and", - }) - - // 3) 글로벌 검색 - let globalWhere - if (input.search) { - const s = `%${input.search}%` - globalWhere = or( - sql`${vendorTbeView.vendorName} ILIKE ${s}`, - sql`${vendorTbeView.vendorCode} ILIKE ${s}`, - sql`${vendorTbeView.email} ILIKE ${s}` - ) - } - - // 4) REJECTED 아니거나 NULL - const notRejected = or( - ne(vendorTbeView.rfqVendorStatus, "REJECTED"), - isNull(vendorTbeView.rfqVendorStatus) - ) - - // 5) finalWhere - const finalWhere = and( - isNotNull(vendorTbeView.tbeId), - eq(vendorTbeView.vendorId, vendorId), - // notRejected, - advancedWhere, - globalWhere - ) - - // 6) 정렬 - const orderBy = input.sort?.length - ? input.sort.map((s) => { - const col = (vendorTbeView as any)[s.id] - return s.desc ? desc(col) : asc(col) - }) - : [asc(vendorTbeView.vendorId)] - - // 7) 메인 SELECT - vendor 기준으로 GROUP BY - const [rows, total] = await db.transaction(async (tx) => { - const data = await tx - .select({ - // 원하는 컬럼들 - id: vendorTbeView.vendorId, - tbeId: vendorTbeView.tbeId, - vendorId: vendorTbeView.vendorId, - vendorName: vendorTbeView.vendorName, - vendorCode: vendorTbeView.vendorCode, - address: vendorTbeView.address, - country: vendorTbeView.country, - email: vendorTbeView.email, - website: vendorTbeView.website, - vendorStatus: vendorTbeView.vendorStatus, - - rfqId: vendorTbeView.rfqId, - rfqCode: vendorTbeView.rfqCode, - - rfqStatus:vendorTbeView.rfqStatus, - rfqDescription: vendorTbeView.description, - rfqDueDate: vendorTbeView.dueDate, - - projectCode: vendorTbeView.projectCode, - projectName: vendorTbeView.projectName, - description: vendorTbeView.description, - dueDate: vendorTbeView.dueDate, - - vendorResponseId: vendorTbeView.vendorResponseId, - rfqVendorStatus: vendorTbeView.rfqVendorStatus, - rfqVendorUpdated: vendorTbeView.rfqVendorUpdated, - - tbeResult: vendorTbeView.tbeResult, - tbeNote: vendorTbeView.tbeNote, - tbeUpdated: vendorTbeView.tbeUpdated, - }) - .from(vendorTbeView) - .where(finalWhere) - .orderBy(...orderBy) - .offset(offset) - .limit(limit) - - const [{ count }] = await tx - .select({ count: sql<number>`count(*)`.as("count") }) - .from(vendorTbeView) - .where(finalWhere) - - return [data, Number(count)] - }) - - if (!rows.length) { - return { data: [], pageCount: 0 } - } - - // 8) Comments 조회 - // - evaluationId != null && evalType = "TBE" - // - => leftJoin(rfqEvaluations) or innerJoin - const distinctVendorIds = [...new Set(rows.map((r) => r.vendorId))] - const distinctTbeIds = [...new Set(rows.map((r) => r.tbeId).filter(Boolean))] - - // (A) 조인 방식 - const commAll = await db - .select({ - id: rfqComments.id, - commentText: rfqComments.commentText, - vendorId: rfqComments.vendorId, - evaluationId: rfqComments.evaluationId, - createdAt: rfqComments.createdAt, - commentedBy: rfqComments.commentedBy, - evalType: rfqEvaluations.evalType, // (optional) - }) - .from(rfqComments) - // evalType = 'TBE' - .innerJoin( - rfqEvaluations, - and( - eq(rfqEvaluations.id, rfqComments.evaluationId), - eq(rfqEvaluations.evalType, "TBE") // ★ TBE만 - ) - ) - .where( - and( - isNotNull(rfqComments.evaluationId), - inArray(rfqComments.vendorId, distinctVendorIds) - ) - ) - - // 8-A) vendorId -> comments grouping - const commByVendorId = new Map<number, any[]>() - for (const c of commAll) { - const vid = c.vendorId! - if (!commByVendorId.has(vid)) { - commByVendorId.set(vid, []) - } - commByVendorId.get(vid)!.push({ - id: c.id, - commentText: c.commentText, - vendorId: c.vendorId, - evaluationId: c.evaluationId, - createdAt: c.createdAt, - commentedBy: c.commentedBy, - }) - } - - // 9) TBE 템플릿 파일 수 조회 - const templateFiles = await db - .select({ - tbeId: rfqAttachments.evaluationId, - fileCount: sql<number>`count(*)`.as("file_count"), - }) - .from(rfqAttachments) - .where( - and( - inArray(rfqAttachments.evaluationId, distinctTbeIds), - isNull(rfqAttachments.vendorId), - isNull(rfqAttachments.commentId) - ) - ) - .groupBy(rfqAttachments.evaluationId) - - // tbeId -> fileCount 매핑 - null 체크 추가 - const templateFileCountMap = new Map<number, number>() - for (const tf of templateFiles) { - if (tf.tbeId !== null) { - templateFileCountMap.set(tf.tbeId, Number(tf.fileCount)) - } - } - - // 10) TBE 응답 파일 확인 (각 tbeId + vendorId 조합에 대해) - const tbeResponseFiles = await db - .select({ - tbeId: rfqAttachments.evaluationId, - vendorId: rfqAttachments.vendorId, - responseFileCount: sql<number>`count(*)`.as("response_file_count"), - }) - .from(rfqAttachments) - .where( - and( - inArray(rfqAttachments.evaluationId, distinctTbeIds), - inArray(rfqAttachments.vendorId, distinctVendorIds), - isNull(rfqAttachments.commentId) - ) - ) - .groupBy(rfqAttachments.evaluationId, rfqAttachments.vendorId) - - // 10-A) TBE 제출 파일 상세 정보 조회 (vendor별로 그룹화) - - - // tbeId_vendorId -> hasResponse 매핑 - null 체크 추가 - const tbeResponseMap = new Map<string, number>() - for (const rf of tbeResponseFiles) { - if (rf.tbeId !== null && rf.vendorId !== null) { - const key = `${rf.tbeId}_${rf.vendorId}` - tbeResponseMap.set(key, Number(rf.responseFileCount)) - } - } - - // 11) 최종 합치기 - const final = rows.map((row) => { - const tbeId = row.tbeId - const vendorId = row.vendorId - - // 템플릿 파일 수 - const templateFileCount = tbeId !== null ? templateFileCountMap.get(tbeId) || 0 : 0 - - // 응답 파일 여부 - const responseKey = tbeId !== null ? `${tbeId}_${vendorId}` : "" - const responseFileCount = responseKey ? tbeResponseMap.get(responseKey) || 0 : 0 - - return { - ...row, - dueDate: row.dueDate ? new Date(row.dueDate) : null, - comments: commByVendorId.get(row.vendorId) ?? [], - templateFileCount, // 추가: 템플릿 파일 수 - hasResponse: responseFileCount > 0, // 추가: 응답 파일 제출 여부 - } - }) - - const pageCount = Math.ceil(total / limit) - return { data: final, pageCount } - } catch (err) { - return { data: null, error: getErrorMessage(err) }; - } -} - -export async function inviteTbeVendorsAction(formData: FormData) { - // 캐싱 방지 - try { - // 1) FormData에서 기본 필드 추출 - const rfqId = Number(formData.get("rfqId")) - const vendorIdsRaw = formData.getAll("vendorIds[]") - const vendorIds = vendorIdsRaw.map((id) => Number(id)) - - // 2) FormData에서 파일들 추출 (multiple) - const tbeFiles = formData.getAll("tbeFiles") as File[] - if (!rfqId || !vendorIds.length || !tbeFiles.length) { - throw new Error("Invalid input or no files attached.") - } - - - // DB 트랜잭션 - await db.transaction(async (tx) => { - // (A) RFQ 기본 정보 조회 - const [rfqRow] = await tx - .select({ - rfqCode: vendorResponsesView.rfqCode, - description: vendorResponsesView.rfqDescription, - projectCode: vendorResponsesView.projectCode, - projectName: vendorResponsesView.projectName, - dueDate: vendorResponsesView.rfqDueDate, - createdBy: vendorResponsesView.rfqCreatedBy, - }) - .from(vendorResponsesView) - .where(eq(vendorResponsesView.rfqId, rfqId)) - - if (!rfqRow) { - throw new Error(`RFQ #${rfqId} not found`) - } - - // (B) RFQ 아이템 목록 - const items = await tx - .select({ - itemCode: rfqItems.itemCode, - description: rfqItems.description, - quantity: rfqItems.quantity, - uom: rfqItems.uom, - }) - .from(rfqItems) - .where(eq(rfqItems.rfqId, rfqId)) - - // (C) 대상 벤더들 (이메일 정보 확장) - const vendorRows = await tx - .select({ - id: vendors.id, - name: vendors.vendorName, - email: vendors.email, - representativeEmail: vendors.representativeEmail // 대표자 이메일 추가 - }) - .from(vendors) - .where(sql`${vendors.id} in (${vendorIds})`) - - // (D) 모든 TBE 파일 저장 & 이후 협력업체 초대 처리 - // 파일은 한 번만 저장해도 되지만, 각 벤더별로 따로 저장/첨부가 필요하다면 루프를 돌려도 됨. - // 여기서는 "모든 파일"을 RFQ-DIR에 저장 + "각 협력업체"에는 동일 파일 목록을 첨부한다는 예시. - const savedFiles = [] - for (const file of tbeFiles) { - - const saveResult = await saveDRMFile(file, decryptWithServerAction, `rfqTech/${rfqId}`) - - // 저장 경로 & 파일명 기록 - savedFiles.push({ - fileName: file.name, // 원본 파일명으로 첨부 - filePath: saveResult.publicPath, // public 이하 경로 - absolutePath: saveResult.publicPath, - }) - } - - // (E) 각 벤더별로 TBE 평가 레코드, 초대 처리, 메일 발송 - for (const vendor of vendorRows) { - // 1) 협력업체 연락처 조회 - 추가 이메일 수집 - const contacts = await tx - .select({ - contactName: vendorContacts.contactName, - contactEmail: vendorContacts.contactEmail, - isPrimary: vendorContacts.isPrimary, - }) - .from(vendorContacts) - .where(eq(vendorContacts.vendorId, vendor.id)) - - // 2) 모든 이메일 주소 수집 및 중복 제거 - const allEmails = new Set<string>() - - // 협력업체 이메일 추가 (있는 경우에만) - if (vendor.email) { - allEmails.add(vendor.email.trim().toLowerCase()) - } - - // 협력업체 대표자 이메일 추가 (있는 경우에만) - if (vendor.representativeEmail) { - allEmails.add(vendor.representativeEmail.trim().toLowerCase()) - } - - // 연락처 이메일 추가 - contacts.forEach(contact => { - if (contact.contactEmail) { - allEmails.add(contact.contactEmail.trim().toLowerCase()) - } - }) - - // 중복이 제거된 이메일 주소 배열로 변환 - const uniqueEmails = Array.from(allEmails) - - if (uniqueEmails.length === 0) { - console.warn(`협력업체 ID ${vendor.id}에 등록된 이메일 주소가 없습니다. TBE 초대를 건너뜁니다.`) - continue - } - - // 3) TBE 평가 레코드 생성 - const [evalRow] = await tx - .insert(rfqEvaluations) - .values({ - rfqId, - vendorId: vendor.id, - evalType: "TBE", - }) - .returning({ id: rfqEvaluations.id }) - - // 4) rfqAttachments에 저장한 파일들을 기록 - for (const sf of savedFiles) { - await tx.insert(rfqAttachments).values({ - rfqId, - vendorId: vendor.id, - evaluationId: evalRow.id, - fileName: sf.fileName, - filePath: sf.filePath, - }) - } - - // 5) 각 고유 이메일 주소로 초대 메일 발송 - const baseUrl = process.env.NEXT_PUBLIC_BASE_URL || 'http://3.36.56.124:3000' - const loginUrl = `${baseUrl}/ko/partners/rfq-tech` - - console.log(`협력업체 ID ${vendor.id}(${vendor.name})에 대해 ${uniqueEmails.length}개의 고유 이메일로 TBE 초대 발송`) - - for (const email of uniqueEmails) { - try { - // 연락처 이름 찾기 (이메일과 일치하는 연락처가 있으면 사용, 없으면 '벤더명 담당자'로 대체) - const contact = contacts.find(c => - c.contactEmail && c.contactEmail.toLowerCase() === email.toLowerCase() - ) - const contactName = contact?.contactName || `${vendor.name} 담당자` - - await sendEmail({ - to: email, - subject: `[RFQ ${rfqRow.rfqCode}] You are invited for TBE!`, - template: "rfq-invite", - context: { - language: "en", - rfqId, - vendorId: vendor.id, - contactName, // 연락처 이름 추가 - rfqCode: rfqRow.rfqCode, - projectCode: rfqRow.projectCode, - projectName: rfqRow.projectName, - dueDate: rfqRow.dueDate, - description: rfqRow.description, - items: items.map((it) => ({ - itemCode: it.itemCode, - description: it.description, - quantity: it.quantity, - uom: it.uom, - })), - loginUrl, - }, - attachments: savedFiles.map((sf) => ({ - path: sf.absolutePath, - filename: sf.fileName, - })), - }) - console.log(`이메일 전송 성공: ${email} (${contactName})`) - } catch (emailErr) { - console.error(`이메일 전송 실패 (${email}):`, emailErr) - } - } - } - - }) - - // 성공 - return { error: null } - } catch (err) { - console.error("[inviteTbeVendorsAction] Error:", err) - return { error: getErrorMessage(err) } - } -} -////partners - - -export async function modifyRfqVendor(input: UpdateRfqVendorSchema) { - try { - const data = await db.transaction(async (tx) => { - const [res] = await updateRfqVendor(tx, input.id, { - responseStatus: input.status, - }); - return res; - }); - - return { data: null, error: null }; - } catch (err) { - return { data: null, error: getErrorMessage(err) }; - } -} - -export async function createRfqCommentWithAttachments(params: { - rfqId: number - vendorId?: number | null - commentText: string - commentedBy: number - evaluationId?: number | null - cbeId?: number | null - files?: File[] -}) { - const { rfqId, vendorId, commentText, commentedBy, evaluationId,cbeId, files } = params - console.log("cbeId", cbeId) - console.log("evaluationId", evaluationId) - // 1) 새로운 코멘트 생성 - const [insertedComment] = await db - .insert(rfqComments) - .values({ - rfqId, - vendorId: vendorId || null, - commentText, - commentedBy, - evaluationId: evaluationId || null, - cbeId: cbeId || null, - }) - .returning({ id: rfqComments.id, createdAt: rfqComments.createdAt }) // id만 반환하도록 - - if (!insertedComment) { - throw new Error("Failed to create comment") - } - - // 2) 첨부파일 처리 - if (files && files.length > 0) { - - for (const file of files) { - - const saveResult = await saveDRMFile(file, decryptWithServerAction, `rfqTech/${rfqId}`) - - // DB에 첨부파일 row 생성 - await db.insert(rfqAttachments).values({ - rfqId, - vendorId: vendorId || null, - evaluationId: evaluationId || null, - cbeId: cbeId || null, - commentId: insertedComment.id, // 새 코멘트와 연결 - fileName: file.name, - filePath: saveResult.publicPath!, - }) - } - } - return { ok: true, commentId: insertedComment.id, createdAt: insertedComment.createdAt } -} - -export async function fetchRfqAttachmentsbyCommentId(commentId: number) { - // DB select - const rows = await db - .select() - .from(rfqAttachments) - .where(eq(rfqAttachments.commentId, commentId)) - - // rows: { id, fileName, filePath, createdAt, vendorId, ... } - // 필요 없는 필드는 omit하거나 transform 가능 - return rows.map((row) => ({ - id: row.id, - fileName: row.fileName, - filePath: row.filePath, - createdAt: row.createdAt, // or string - vendorId: row.vendorId, - evaluationId: row.evaluationId, - size: undefined, // size를 DB에 저장하지 않았다면 - })) -} - -export async function updateRfqComment(params: { - commentId: number - commentText: string -}) { - const { commentId, commentText } = params - - // 예: 간단한 길이 체크 등 유효성 검사 - if (!commentText || commentText.trim().length === 0) { - throw new Error("Comment text must not be empty.") - } - - // DB 업데이트 - const updatedRows = await db - .update(rfqComments) - .set({ commentText }) // 필요한 컬럼만 set - .where(eq(rfqComments.id, commentId)) - .returning({ id: rfqComments.id }) - - // 혹은 returning 전체(row)를 받아서 확인할 수도 있음 - if (updatedRows.length === 0) { - // 해당 id가 없으면 예외 - throw new Error("Comment not found or already deleted.") - } - - return { ok: true } -} - -export type Project = { - id: number; - projectCode: string; - projectName: string; -} - -export async function getProjects(): Promise<Project[]> { - try { - // 트랜잭션을 사용하여 프로젝트 데이터 조회 - const projectList = await db.transaction(async (tx) => { - // 모든 프로젝트 조회 - const results = await tx - .select({ - id: projects.id, - projectCode: projects.code, // 테이블의 실제 컬럼명에 맞게 조정 - projectName: projects.name, // 테이블의 실제 컬럼명에 맞게 조정 - }) - .from(projects) - .orderBy(projects.code); - - return results; - }); - - return projectList; - } catch (error) { - console.error("프로젝트 목록 가져오기 실패:", error); - return []; // 오류 발생 시 빈 배열 반환 - } -} - - -export async function getBidProjects(): Promise<Project[]> { - try { - // 트랜잭션을 사용하여 프로젝트 데이터 조회 - const projectList = await db.transaction(async (tx) => { - // 모든 프로젝트 조회 - const results = await tx - .select({ - id: biddingProjects.id, - projectCode: biddingProjects.pspid, - projectName: biddingProjects.projNm, - }) - .from(biddingProjects) - .orderBy(biddingProjects.id); - - return results; - }); - - // Handle null projectName values - const validProjectList = projectList.map(project => ({ - ...project, - projectName: project.projectName || '' // Replace null with empty string - })); - - return validProjectList; - } catch (error) { - console.error("프로젝트 목록 가져오기 실패:", error); - return []; // 오류 발생 시 빈 배열 반환 - } -} - -export async function getAllVendors(input?: { - page?: number; - perPage?: number; - search?: string; - filters?: any[]; - sort?: { id: string; desc: boolean }[]; - joinOperator?: "and" | "or"; -}) { - try { - const page = input?.page ?? 1; - const perPage = input?.perPage ?? 50; - const offset = (page - 1) * perPage; - - // 고급 필터 - const advancedWhere = input?.filters ? filterColumns({ - table: vendors, - filters: input.filters, - joinOperator: input.joinOperator ?? "and", - }) : undefined; - - // 글로벌 검색 - let globalWhere; - if (input?.search) { - const s = `%${input.search}%`; - globalWhere = or( - ilike(vendors.vendorName, s), - ilike(vendors.vendorCode, s), - ilike(vendors.email, s), - ilike(vendors.country, s), - ilike(vendors.phone, s) - ); - } - - // 최종 where 조건 - const finalWhere = and(advancedWhere, globalWhere); - - // 정렬 - const orderBy = input?.sort?.length - ? input.sort.map((s) => { - const col = (vendors as any)[s.id]; - return s.desc ? desc(col) : asc(col); - }) - : [asc(vendors.vendorName)]; // 기본 정렬은 vendor 이름 - - // 데이터 조회 - const [data, total] = await db.transaction(async (tx) => { - const vendorData = await tx - .select() - .from(vendors) - .where(finalWhere) - .orderBy(...orderBy) - .offset(offset) - .limit(perPage); - - const [{ count }] = await tx - .select({ count: sql<number>`count(*)`.as("count") }) - .from(vendors) - .where(finalWhere); - - return [vendorData, Number(count)]; - }); - - const pageCount = Math.ceil(total / perPage); - return { data, pageCount, total }; - } catch (error) { - console.error("Error fetching vendors:", error); - return { data: [], pageCount: 0, total: 0 }; - } -} - - -export async function getVendorContactsByVendorId(vendorId: number) { - try { - const contacts = await db.query.vendorContacts.findMany({ - where: eq(vendorContacts.vendorId, vendorId), - }); - - return { success: true, data: contacts }; - } catch (error) { - console.error("Error fetching vendor contacts:", error); - return { success: false, error: "Failed to fetch vendor contacts" }; - } -} -/** - * Server action to associate items from an RFQ with a vendor - * - * @param rfqId - The ID of the RFQ containing items to associate - * @param vendorId - The ID of the vendor to associate items with - * @returns Object indicating success or failure - */ -export async function addItemToVendors(rfqId: number, vendorIds: number[]) { - try { - // Input validation - if (!vendorIds.length) { - return { - success: false, - error: "No vendors selected" - }; - } - - // 1. Find all itemCodes associated with the given rfqId using select - const rfqItemResults = await db - .select({ itemCode: rfqItems.itemCode }) - .from(rfqItems) - .where(eq(rfqItems.rfqId, rfqId)); - - // Extract itemCodes - const itemCodes = rfqItemResults.map(item => item.itemCode); - - if (itemCodes.length === 0) { - return { - success: false, - error: "No items found for this RFQ" - }; - } - - // 2. Find existing vendor-item combinations to avoid duplicates - const existingCombinations = await db - .select({ - vendorId: vendorPossibleItems.vendorId, - itemCode: vendorPossibleItems.itemCode - }) - .from(vendorPossibleItems) - .where( - and( - inArray(vendorPossibleItems.vendorId, vendorIds), - inArray(vendorPossibleItems.itemCode, itemCodes) - ) - ); - - // Create a Set of existing combinations for easy lookups - const existingSet = new Set(); - existingCombinations.forEach(combo => { - existingSet.add(`${combo.vendorId}-${combo.itemCode}`); - }); - - // 3. Prepare records to insert (only non-existing combinations) - const recordsToInsert = []; - - for (const vendorId of vendorIds) { - for (const itemCode of itemCodes) { - const key = `${vendorId}-${itemCode}`; - if (!existingSet.has(key)) { - recordsToInsert.push({ - vendorId, - itemCode, - // createdAt and updatedAt will be set by defaultNow() - }); - } - } - } - - // 4. Bulk insert if there are records to insert - let insertedCount = 0; - if (recordsToInsert.length > 0) { - const result = await db.insert(vendorPossibleItems).values(recordsToInsert); - insertedCount = recordsToInsert.length; - } - - // 6. Return success with counts - return { - success: true, - insertedCount, - totalPossibleItems: vendorIds.length * itemCodes.length, - vendorCount: vendorIds.length, - itemCount: itemCodes.length - }; - } catch (error) { - console.error("Error adding items to vendors:", error); - return { - success: false, - error: error instanceof Error ? error.message : "Unknown error" - }; - } -} - -/** - * 특정 평가에 대한 TBE 템플릿 파일 목록 조회 - * evaluationId가 일치하고 vendorId가 null인 파일 목록 - */ -export async function fetchTbeTemplateFiles(evaluationId: number) { - try { - const files = await db - .select({ - id: rfqAttachments.id, - fileName: rfqAttachments.fileName, - filePath: rfqAttachments.filePath, - createdAt: rfqAttachments.createdAt, - }) - .from(rfqAttachments) - .where( - and( - isNull(rfqAttachments.commentId), - isNull(rfqAttachments.vendorId), - eq(rfqAttachments.evaluationId, evaluationId), - // eq(rfqAttachments.vendorId, vendorId), - - ) - ) - - return { files, error: null } - } catch (error) { - console.error("Error fetching TBE template files:", error) - return { - files: [], - error: "템플릿 파일을 가져오는 중 오류가 발생했습니다." - } - } -} - -export async function getFileFromRfqAttachmentsbyid(fileId: number) { - try { - const file = await db - .select({ - fileName: rfqAttachments.fileName, - filePath: rfqAttachments.filePath, - }) - .from(rfqAttachments) - .where(eq(rfqAttachments.id, fileId)) - .limit(1) - - if (!file.length) { - return { file: null, error: "파일을 찾을 수 없습니다." } - } - - return { file: file[0], error: null } - } catch (error) { - console.error("Error getting TBE template file info:", error) - return { - file: null, - error: "파일 정보를 가져오는 중 오류가 발생했습니다." - } - } -} - -/** - * TBE 응답 파일 업로드 처리 - */ -export async function uploadTbeResponseFile(formData: FormData) { - try { - const file = formData.get("file") as File - const rfqId = parseInt(formData.get("rfqId") as string) - const vendorId = parseInt(formData.get("vendorId") as string) - const evaluationId = parseInt(formData.get("evaluationId") as string) - const vendorResponseId = parseInt(formData.get("vendorResponseId") as string) - - if (!file || !rfqId || !vendorId || !evaluationId) { - return { - success: false, - error: "필수 필드가 누락되었습니다." - } - } - - const saveResult = await saveFile({file, directory:`rfqTech/${rfqId}/tbe-responses`}) - - // 먼저 vendorTechnicalResponses 테이블에 엔트리 생성 - const technicalResponse = await db.insert(vendorTechnicalResponses) - .values({ - responseId: vendorResponseId, - summary: "TBE 응답 파일 업로드", // 필요에 따라 수정 - notes: `파일명: ${file.name}`, - responseStatus:"SUBMITTED" - }) - .returning({ id: vendorTechnicalResponses.id }); - - // 생성된 기술 응답 ID 가져오기 - const technicalResponseId = technicalResponse[0].id; - - // vendorResponseAttachments 테이블 스키마에 맞게 데이터 삽입 - await db.insert(vendorResponseAttachments) - .values({ - // 오류 메시지를 기반으로 올바른 필드 이름 사용 - // 테이블 스키마에 정의된 필드만 포함해야 함 - responseId: vendorResponseId, - technicalResponseId: technicalResponseId, - // vendorId와 evaluationId 필드가 테이블에 있다면 포함, 없다면 제거 - // vendorId: vendorId, - // evaluationId: evaluationId, - fileName: file.name, - filePath: saveResult.publicPath!, - uploadedAt: new Date(), - }); - - return { - success: true, - message: "파일이 성공적으로 업로드되었습니다." - } - } catch (error) { - console.error("Error uploading file:", error) - return { - success: false, - error: "파일 업로드에 실패했습니다." - } - } -} - -export async function getTbeSubmittedFiles(responseId: number) { - try { - // First, get the technical response IDs where vendorResponseId matches responseId - const technicalResponses = await db - .select({ - id: vendorTechnicalResponses.id, - }) - .from(vendorTechnicalResponses) - .where( - eq(vendorTechnicalResponses.responseId, responseId) - ) - - if (technicalResponses.length === 0) { - return { files: [], error: null } - } - - // Extract the IDs from the result - const technicalResponseIds = technicalResponses.map(tr => tr.id) - - // Then get attachments where technicalResponseId matches any of the IDs we found - const files = await db - .select({ - id: vendorResponseAttachments.id, - fileName: vendorResponseAttachments.fileName, - filePath: vendorResponseAttachments.filePath, - uploadedAt: vendorResponseAttachments.uploadedAt, - fileType: vendorResponseAttachments.fileType, - attachmentType: vendorResponseAttachments.attachmentType, - description: vendorResponseAttachments.description, - }) - .from(vendorResponseAttachments) - .where( - inArray(vendorResponseAttachments.technicalResponseId, technicalResponseIds) - ) - .orderBy(vendorResponseAttachments.uploadedAt) - - return { files, error: null } - } catch (error) { - return { files: [], error: 'Failed to fetch TBE submitted files' } - } -} - - - -export async function getTbeFilesForVendor(rfqId: number, vendorId: number) { - try { - // Step 1: Get responseId from vendor_responses table - const response = await db - .select({ - id: vendorResponses.id, - }) - .from(vendorResponses) - .where( - and( - eq(vendorResponses.rfqId, rfqId), - eq(vendorResponses.vendorId, vendorId) - ) - ) - .limit(1); - - if (!response || response.length === 0) { - return { files: [], error: 'No vendor response found' }; - } - - const responseId = response[0].id; - - // Step 2: Get the technical response IDs - const technicalResponses = await db - .select({ - id: vendorTechnicalResponses.id, - }) - .from(vendorTechnicalResponses) - .where( - eq(vendorTechnicalResponses.responseId, responseId) - ); - - if (technicalResponses.length === 0) { - return { files: [], error: null }; - } - - // Extract the IDs from the result - const technicalResponseIds = technicalResponses.map(tr => tr.id); - - // Step 3: Get attachments where technicalResponseId matches any of the IDs - const files = await db - .select({ - id: vendorResponseAttachments.id, - fileName: vendorResponseAttachments.fileName, - filePath: vendorResponseAttachments.filePath, - uploadedAt: vendorResponseAttachments.uploadedAt, - fileType: vendorResponseAttachments.fileType, - attachmentType: vendorResponseAttachments.attachmentType, - description: vendorResponseAttachments.description, - }) - .from(vendorResponseAttachments) - .where( - inArray(vendorResponseAttachments.technicalResponseId, technicalResponseIds) - ) - .orderBy(vendorResponseAttachments.uploadedAt); - - return { files, error: null }; - } catch (error) { - return { files: [], error: 'Failed to fetch vendor files' }; - } -} - -export async function getAllTBE(input: GetTBESchema) { - // 1) 페이징 - const offset = ((input.page ?? 1) - 1) * (input.perPage ?? 10) - const limit = input.perPage ?? 10 - - // 2) 고급 필터 - const advancedWhere = filterColumns({ - table: vendorTbeView, - filters: input.filters ?? [], - joinOperator: input.joinOperator ?? "and", - }) - - // 3) 글로벌 검색 - let globalWhere - if (input.search) { - const s = `%${input.search}%` - globalWhere = or( - sql`${vendorTbeView.vendorName} ILIKE ${s}`, - sql`${vendorTbeView.vendorCode} ILIKE ${s}`, - sql`${vendorTbeView.email} ILIKE ${s}`, - sql`${vendorTbeView.rfqCode} ILIKE ${s}`, - sql`${vendorTbeView.projectCode} ILIKE ${s}`, - sql`${vendorTbeView.projectName} ILIKE ${s}` - ) - } - - // 4) REJECTED 아니거나 NULL - const notRejected = or( - ne(vendorTbeView.rfqVendorStatus, "REJECTED"), - isNull(vendorTbeView.rfqVendorStatus) - ) - - const finalWhere = and( - notRejected, - advancedWhere, - globalWhere - ) - - // 6) 정렬 - const orderBy = input.sort?.length - ? input.sort.map((s) => { - const col = (vendorTbeView as any)[s.id] - return s.desc ? desc(col) : asc(col) - }) - : [desc(vendorTbeView.rfqId), asc(vendorTbeView.vendorId)] // Default sort by newest RFQ first - - // 7) 메인 SELECT - const [rows, total] = await db.transaction(async (tx) => { - const data = await tx - .select({ - // 원하는 컬럼들 - id: vendorTbeView.vendorId, - tbeId: vendorTbeView.tbeId, - vendorId: vendorTbeView.vendorId, - vendorName: vendorTbeView.vendorName, - vendorCode: vendorTbeView.vendorCode, - address: vendorTbeView.address, - country: vendorTbeView.country, - email: vendorTbeView.email, - website: vendorTbeView.website, - vendorStatus: vendorTbeView.vendorStatus, - - rfqId: vendorTbeView.rfqId, - rfqCode: vendorTbeView.rfqCode, - projectCode: vendorTbeView.projectCode, - projectName: vendorTbeView.projectName, - description: vendorTbeView.description, - dueDate: vendorTbeView.dueDate, - - rfqVendorStatus: vendorTbeView.rfqVendorStatus, - rfqVendorUpdated: vendorTbeView.rfqVendorUpdated, - - technicalResponseStatus:vendorTbeView.technicalResponseStatus, - tbeResult: vendorTbeView.tbeResult, - - tbeNote: vendorTbeView.tbeNote, - tbeUpdated: vendorTbeView.tbeUpdated, - }) - .from(vendorTbeView) - .where(finalWhere) - .orderBy(...orderBy) - .offset(offset) - .limit(limit) - - const [{ count }] = await tx - .select({ count: sql<number>`count(*)`.as("count") }) - .from(vendorTbeView) - .where(finalWhere) - - return [data, Number(count)] - }) - - if (!rows.length) { - return { data: [], pageCount: 0 } - } - - // 8) Get distinct rfqIds and vendorIds - filter out nulls - const distinctVendorIds = [...new Set(rows.map((r) => r.vendorId).filter(Boolean))] as number[]; - const distinctRfqIds = [...new Set(rows.map((r) => r.rfqId).filter(Boolean))] as number[]; - - // 9) Comments 조회 - const commentsConditions = [isNotNull(rfqComments.evaluationId)]; - - // 배열이 비어있지 않을 때만 조건 추가 - if (distinctRfqIds.length > 0) { - commentsConditions.push(inArray(rfqComments.rfqId, distinctRfqIds)); - } - - if (distinctVendorIds.length > 0) { - commentsConditions.push(inArray(rfqComments.vendorId, distinctVendorIds)); - } - - const commAll = await db - .select({ - id: rfqComments.id, - commentText: rfqComments.commentText, - vendorId: rfqComments.vendorId, - rfqId: rfqComments.rfqId, - evaluationId: rfqComments.evaluationId, - createdAt: rfqComments.createdAt, - commentedBy: rfqComments.commentedBy, - evalType: rfqEvaluations.evalType, - }) - .from(rfqComments) - .innerJoin( - rfqEvaluations, - and( - eq(rfqEvaluations.id, rfqComments.evaluationId), - eq(rfqEvaluations.evalType, "TBE") - ) - ) - .where(and(...commentsConditions)); - - // 9-A) Create a composite key (rfqId-vendorId) -> comments mapping - const commByCompositeKey = new Map<string, any[]>() - for (const c of commAll) { - if (!c.rfqId || !c.vendorId) continue; - - const compositeKey = `${c.rfqId}-${c.vendorId}`; - if (!commByCompositeKey.has(compositeKey)) { - commByCompositeKey.set(compositeKey, []) - } - commByCompositeKey.get(compositeKey)!.push({ - id: c.id, - commentText: c.commentText, - vendorId: c.vendorId, - evaluationId: c.evaluationId, - createdAt: c.createdAt, - commentedBy: c.commentedBy, - }) - } - - // 10) Responses 조회 - const responsesAll = await db - .select({ - id: vendorResponses.id, - rfqId: vendorResponses.rfqId, - vendorId: vendorResponses.vendorId - }) - .from(vendorResponses) - .where( - and( - inArray(vendorResponses.rfqId, distinctRfqIds), - inArray(vendorResponses.vendorId, distinctVendorIds) - ) - ); - - // Group responses by rfqId-vendorId composite key - const responsesByCompositeKey = new Map<string, number[]>(); - for (const resp of responsesAll) { - const compositeKey = `${resp.rfqId}-${resp.vendorId}`; - if (!responsesByCompositeKey.has(compositeKey)) { - responsesByCompositeKey.set(compositeKey, []); - } - responsesByCompositeKey.get(compositeKey)!.push(resp.id); - } - - // Get all responseIds - const allResponseIds = responsesAll.map(r => r.id); - - // 11) Get technicalResponses for these responseIds - const technicalResponsesAll = await db - .select({ - id: vendorTechnicalResponses.id, - responseId: vendorTechnicalResponses.responseId - }) - .from(vendorTechnicalResponses) - .where(inArray(vendorTechnicalResponses.responseId, allResponseIds)); - - // Create mapping from responseId to technicalResponseIds - const technicalResponseIdsByResponseId = new Map<number, number[]>(); - for (const tr of technicalResponsesAll) { - if (!technicalResponseIdsByResponseId.has(tr.responseId)) { - technicalResponseIdsByResponseId.set(tr.responseId, []); - } - technicalResponseIdsByResponseId.get(tr.responseId)!.push(tr.id); - } - - // Get all technicalResponseIds - const allTechnicalResponseIds = technicalResponsesAll.map(tr => tr.id); - - // 12) Get attachments for these technicalResponseIds - const filesAll = await db - .select({ - id: vendorResponseAttachments.id, - fileName: vendorResponseAttachments.fileName, - filePath: vendorResponseAttachments.filePath, - technicalResponseId: vendorResponseAttachments.technicalResponseId, - fileType: vendorResponseAttachments.fileType, - attachmentType: vendorResponseAttachments.attachmentType, - description: vendorResponseAttachments.description, - uploadedAt: vendorResponseAttachments.uploadedAt, - uploadedBy: vendorResponseAttachments.uploadedBy - }) - .from(vendorResponseAttachments) - .where( - and( - inArray(vendorResponseAttachments.technicalResponseId, allTechnicalResponseIds), - isNotNull(vendorResponseAttachments.technicalResponseId) - ) - ); - - // Create mapping from technicalResponseId to attachments - const filesByTechnicalResponseId = new Map<number, any[]>(); - for (const file of filesAll) { - if (file.technicalResponseId === null) continue; - - if (!filesByTechnicalResponseId.has(file.technicalResponseId)) { - filesByTechnicalResponseId.set(file.technicalResponseId, []); - } - filesByTechnicalResponseId.get(file.technicalResponseId)!.push({ - id: file.id, - fileName: file.fileName, - filePath: file.filePath, - fileType: file.fileType, - attachmentType: file.attachmentType, - description: file.description, - uploadedAt: file.uploadedAt, - uploadedBy: file.uploadedBy - }); - } - - // 13) Create the final filesByCompositeKey map - const filesByCompositeKey = new Map<string, any[]>(); - - for (const [compositeKey, responseIds] of responsesByCompositeKey.entries()) { - filesByCompositeKey.set(compositeKey, []); - - for (const responseId of responseIds) { - const technicalResponseIds = technicalResponseIdsByResponseId.get(responseId) || []; - - for (const technicalResponseId of technicalResponseIds) { - const files = filesByTechnicalResponseId.get(technicalResponseId) || []; - filesByCompositeKey.get(compositeKey)!.push(...files); - } - } - } - - // 14) 최종 합치기 - const final = rows.map((row) => { - const compositeKey = `${row.rfqId}-${row.vendorId}`; - - return { - ...row, - dueDate: row.dueDate ? new Date(row.dueDate) : null, - comments: commByCompositeKey.get(compositeKey) ?? [], - files: filesByCompositeKey.get(compositeKey) ?? [], - }; - }) - - const pageCount = Math.ceil(total / limit) - return { data: final, pageCount } -} - - -export async function getCBE(input: GetCBESchema, rfqId: number) { - // [1] 페이징 - const offset = ((input.page ?? 1) - 1) * (input.perPage ?? 10); - const limit = input.perPage ?? 10; - - // [2] 고급 필터 - const advancedWhere = filterColumns({ - table: vendorResponseCBEView, - filters: input.filters ?? [], - joinOperator: input.joinOperator ?? "and", - }); - - // [3] 글로벌 검색 - let globalWhere; - if (input.search) { - const s = `%${input.search}%`; - globalWhere = or( - sql`${vendorResponseCBEView.vendorName} ILIKE ${s}`, - sql`${vendorResponseCBEView.vendorCode} ILIKE ${s}`, - sql`${vendorResponseCBEView.rfqCode} ILIKE ${s}`, - sql`${vendorResponseCBEView.totalPrice}::text ILIKE ${s}` - ); - } - - // [4] DECLINED 상태 제외 (거절된 업체는 표시하지 않음) - const notDeclined = ne(vendorResponseCBEView.responseStatus, "DECLINED"); - - // [5] 최종 where 조건 - const finalWhere = and( - eq(vendorResponseCBEView.rfqId, rfqId), - notDeclined, - advancedWhere ?? undefined, - globalWhere ?? undefined - ); - - // [6] 정렬 - const orderBy = input.sort?.length - ? input.sort.map((s) => { - // vendorResponseCBEView 컬럼 중 정렬 대상이 되는 것만 매핑 - const col = (vendorResponseCBEView as any)[s.id]; - return s.desc ? desc(col) : asc(col); - }) - : [asc(vendorResponseCBEView.vendorName)]; // 기본 정렬은 벤더명 - - // [7] 메인 SELECT - const [rows, total] = await db.transaction(async (tx) => { - const data = await tx - .select({ - // 기본 식별 정보 - responseId: vendorResponseCBEView.responseId, - vendorId: vendorResponseCBEView.vendorId, - rfqId: vendorResponseCBEView.rfqId, - - // 협력업체 정보 - vendorName: vendorResponseCBEView.vendorName, - vendorCode: vendorResponseCBEView.vendorCode, - vendorStatus: vendorResponseCBEView.vendorStatus, - - // RFQ 정보 - rfqCode: vendorResponseCBEView.rfqCode, - rfqDescription: vendorResponseCBEView.rfqDescription, - rfqDueDate: vendorResponseCBEView.rfqDueDate, - rfqStatus: vendorResponseCBEView.rfqStatus, - - // 프로젝트 정보 - projectId: vendorResponseCBEView.projectId, - projectCode: vendorResponseCBEView.projectCode, - projectName: vendorResponseCBEView.projectName, - - // 응답 상태 정보 - responseStatus: vendorResponseCBEView.responseStatus, - responseNotes: vendorResponseCBEView.notes, - respondedAt: vendorResponseCBEView.respondedAt, - respondedBy: vendorResponseCBEView.respondedBy, - - // 상업 응답 정보 - commercialResponseId: vendorResponseCBEView.commercialResponseId, - commercialResponseStatus: vendorResponseCBEView.commercialResponseStatus, - totalPrice: vendorResponseCBEView.totalPrice, - currency: vendorResponseCBEView.currency, - paymentTerms: vendorResponseCBEView.paymentTerms, - incoterms: vendorResponseCBEView.incoterms, - deliveryPeriod: vendorResponseCBEView.deliveryPeriod, - warrantyPeriod: vendorResponseCBEView.warrantyPeriod, - validityPeriod: vendorResponseCBEView.validityPeriod, - commercialNotes: vendorResponseCBEView.commercialNotes, - - // 첨부파일 카운트 - attachmentCount: vendorResponseCBEView.attachmentCount, - commercialAttachmentCount: vendorResponseCBEView.commercialAttachmentCount, - technicalAttachmentCount: vendorResponseCBEView.technicalAttachmentCount, - }) - .from(vendorResponseCBEView) - .where(finalWhere) - .orderBy(...orderBy) - .offset(offset) - .limit(limit); - - const [{ count }] = await tx - .select({ count: sql<number>`count(*)`.as("count") }) - .from(vendorResponseCBEView) - .where(finalWhere); - - return [data, Number(count)]; - }); - - if (!rows.length) { - return { data: [], pageCount: 0, total: 0 }; - } - - // [8] 협력업체 ID 목록 추출 - const distinctVendorIds = [...new Set(rows.map((r) => r.vendorId))]; - const distinctResponseIds = [...new Set(rows.map((r) => r.responseId))]; - const distinctCommercialResponseIds = [...new Set(rows.filter(r => r.commercialResponseId).map((r) => r.commercialResponseId!))]; - - // [9] CBE 평가 관련 코멘트 조회 - const commentsAll = await db - .select({ - id: rfqComments.id, - commentText: rfqComments.commentText, - vendorId: rfqComments.vendorId, - cbeId: rfqComments.cbeId, - createdAt: rfqComments.createdAt, - commentedBy: rfqComments.commentedBy, - }) - .from(rfqComments) - .innerJoin( - vendorResponses, - eq(vendorResponses.id, rfqComments.cbeId) - ) - .where( - and( - isNotNull(rfqComments.cbeId), - eq(rfqComments.rfqId, rfqId), - inArray(rfqComments.vendorId, distinctVendorIds) - ) - ); - - // vendorId별 코멘트 그룹화 - const commentsByVendorId = new Map<number, any[]>(); - for (const comment of commentsAll) { - const vendorId = comment.vendorId!; - if (!commentsByVendorId.has(vendorId)) { - commentsByVendorId.set(vendorId, []); - } - commentsByVendorId.get(vendorId)!.push({ - id: comment.id, - commentText: comment.commentText, - vendorId: comment.vendorId, - cbeId: comment.cbeId, - createdAt: comment.createdAt, - commentedBy: comment.commentedBy, - }); - } - - // [10] 첨부 파일 조회 - 일반 응답 첨부파일 - const responseAttachments = await db - .select({ - id: vendorResponseAttachments.id, - fileName: vendorResponseAttachments.fileName, - filePath: vendorResponseAttachments.filePath, - responseId: vendorResponseAttachments.responseId, - fileType: vendorResponseAttachments.fileType, - attachmentType: vendorResponseAttachments.attachmentType, - description: vendorResponseAttachments.description, - uploadedAt: vendorResponseAttachments.uploadedAt, - uploadedBy: vendorResponseAttachments.uploadedBy, - }) - .from(vendorResponseAttachments) - .where( - and( - inArray(vendorResponseAttachments.responseId, distinctResponseIds), - isNotNull(vendorResponseAttachments.responseId) - ) - ); - - // [11] 첨부 파일 조회 - 상업 응답 첨부파일 - const commercialResponseAttachments = await db - .select({ - id: vendorResponseAttachments.id, - fileName: vendorResponseAttachments.fileName, - filePath: vendorResponseAttachments.filePath, - commercialResponseId: vendorResponseAttachments.commercialResponseId, - fileType: vendorResponseAttachments.fileType, - attachmentType: vendorResponseAttachments.attachmentType, - description: vendorResponseAttachments.description, - uploadedAt: vendorResponseAttachments.uploadedAt, - uploadedBy: vendorResponseAttachments.uploadedBy, - }) - .from(vendorResponseAttachments) - .where( - and( - inArray(vendorResponseAttachments.commercialResponseId, distinctCommercialResponseIds), - isNotNull(vendorResponseAttachments.commercialResponseId) - ) - ); - - // [12] 첨부파일 그룹화 - // responseId별 첨부파일 맵 생성 - const filesByResponseId = new Map<number, any[]>(); - for (const file of responseAttachments) { - const responseId = file.responseId!; - if (!filesByResponseId.has(responseId)) { - filesByResponseId.set(responseId, []); - } - filesByResponseId.get(responseId)!.push({ - id: file.id, - fileName: file.fileName, - filePath: file.filePath, - fileType: file.fileType, - attachmentType: file.attachmentType, - description: file.description, - uploadedAt: file.uploadedAt, - uploadedBy: file.uploadedBy, - attachmentSource: 'response' - }); - } - - // commercialResponseId별 첨부파일 맵 생성 - const filesByCommercialResponseId = new Map<number, any[]>(); - for (const file of commercialResponseAttachments) { - const commercialResponseId = file.commercialResponseId!; - if (!filesByCommercialResponseId.has(commercialResponseId)) { - filesByCommercialResponseId.set(commercialResponseId, []); - } - filesByCommercialResponseId.get(commercialResponseId)!.push({ - id: file.id, - fileName: file.fileName, - filePath: file.filePath, - fileType: file.fileType, - attachmentType: file.attachmentType, - description: file.description, - uploadedAt: file.uploadedAt, - uploadedBy: file.uploadedBy, - attachmentSource: 'commercial' - }); - } - - // [13] 최종 데이터 병합 - const final = rows.map((row) => { - // 해당 응답의 모든 첨부파일 가져오기 - const responseFiles = filesByResponseId.get(row.responseId) || []; - const commercialFiles = row.commercialResponseId - ? filesByCommercialResponseId.get(row.commercialResponseId) || [] - : []; - - // 모든 첨부파일 병합 - const allFiles = [...responseFiles, ...commercialFiles]; - - return { - ...row, - rfqDueDate: row.rfqDueDate ? new Date(row.rfqDueDate) : null, - respondedAt: row.respondedAt ? new Date(row.respondedAt) : null, - comments: commentsByVendorId.get(row.vendorId) || [], - files: allFiles, - }; - }); - - const pageCount = Math.ceil(total / limit); - return { - data: final, - pageCount, - total - }; -} - -export async function generateNextRfqCode(): Promise<{ code: string; error?: string }> { - try { - // 현재 연도 가져오기 - const currentYear = new Date().getFullYear(); - - // 현재 연도와 타입에 맞는 최신 RFQ 코드 찾기 - const latestRfqs = await db.select({ rfqCode: rfqs.rfqCode }) - .from(rfqs) - .where(and( - sql`SUBSTRING(${rfqs.rfqCode}, 5, 4) = ${currentYear.toString()}`, - )) - .orderBy(desc(rfqs.rfqCode)) - .limit(1); - - let sequenceNumber = 1; - - if (latestRfqs.length > 0 && latestRfqs[0].rfqCode) { - // null 체크 추가 - TypeScript 오류 해결 - const latestCode = latestRfqs[0].rfqCode; - const matches = latestCode.match(/[A-Z]+-\d{4}-(\d{3})/); - - if (matches && matches[1]) { - sequenceNumber = parseInt(matches[1], 10) + 1; - } - } - - // 새로운 RFQ 코드 포맷팅 - const newCode = `RFQ-${currentYear}-${String(sequenceNumber).padStart(3, '0')}`; - - return { code: newCode }; - } catch (error) { - console.error('Error generating next RFQ code:', error); - return { code: "", error: '코드 생성에 실패했습니다' }; - } -} - -interface SaveTbeResultParams { - id: number // id from the rfq_evaluations table - vendorId: number // vendorId from the rfq_evaluations table - result: string // The selected evaluation result - notes: string // The evaluation notes -} - -export async function saveTbeResult({ - id, - vendorId, - result, - notes, -}: SaveTbeResultParams) { - try { - // Check if we have all required data - if (!id || !vendorId || !result) { - return { - success: false, - message: "Missing required data for evaluation update", - } - } - - // Update the record in the database - await db - .update(rfqEvaluations) - .set({ - result: result, - notes: notes, - updatedAt: new Date(), - }) - .where( - and( - eq(rfqEvaluations.id, id), - eq(rfqEvaluations.vendorId, vendorId), - eq(rfqEvaluations.evalType, "TBE") - ) - ) - - return { - success: true, - message: "TBE evaluation updated successfully", - } - } catch (error) { - console.error("Failed to update TBE evaluation:", error) - - return { - success: false, - message: error instanceof Error ? error.message : "An unknown error occurred", - } - } -} - - -export async function createCbeEvaluation(formData: FormData) { - try { - // 폼 데이터 추출 - const rfqId = Number(formData.get("rfqId")) - const vendorIds = formData.getAll("vendorIds[]").map(id => Number(id)) - const evaluatedBy = formData.get("evaluatedBy") ? Number(formData.get("evaluatedBy")) : null - - - const headersList = await headers(); - const host = headersList.get('host') || 'localhost:3000'; - - // 기본 CBE 데이터 추출 - const rawData = { - rfqId, - paymentTerms: formData.get("paymentTerms") as string, - incoterms: formData.get("incoterms") as string, - deliverySchedule: formData.get("deliverySchedule") as string, - notes: formData.get("notes") as string, - // 단일 협력업체 처리 시 사용할 vendorId (여러 협력업체 처리에선 사용하지 않음) - // vendorId: vendorIds[0] || 0, - } - - // zod 스키마 유효성 검사 (vendorId는 더미로 채워 검증하고 실제로는 배열로 처리) - const validationResult = createCbeEvaluationSchema.safeParse(rawData) - if (!validationResult.success) { - const errors = validationResult.error.format() - console.error("Validation errors:", errors) - return { error: "입력 데이터가 유효하지 않습니다." } - } - - const validData = validationResult.data - - // RFQ 정보 조회 - const [rfqInfo] = await db - .select({ - rfqCode: rfqsView.rfqCode, - projectCode: rfqsView.projectCode, - projectName: rfqsView.projectName, - dueDate: rfqsView.dueDate, - description: rfqsView.description, - }) - .from(rfqsView) - .where(eq(rfqsView.id, rfqId)) - - if (!rfqInfo) { - return { error: "RFQ 정보를 찾을 수 없습니다." } - } - - // 파일 처리 준비 - const files = formData.getAll("files") as File[] - const hasFiles = files && files.length > 0 && files[0].size > 0 - - // 첨부 파일 정보를 저장할 배열 - const attachments: { filename: string; path: string }[] = [] - - // 파일이 있는 경우, 파일을 저장하고 첨부 파일 정보 준비 - if (hasFiles) { - for (const file of files) { - if (file.size > 0) { - try { - const saveResult = await saveDRMFile(file, decryptWithServerAction, `rfqTech/${rfqId}`) - - // 첨부 파일 정보 추가 - attachments.push({ - filename: file.name, - path: saveResult.publicPath!, // 이메일 첨부를 위한 전체 경로 - }) - } catch (err) { - console.error(`파일 저장 실패:`, err) - // 파일 저장 실패를 기록하지만 전체 프로세스는 계속 진행 - } - } - } - } - - // 각 벤더별로 CBE 평가 레코드 생성 및 알림 전송 - const createdCbeIds: number[] = [] - const failedVendors: { id: number, reason: string }[] = [] - - for (const vendorId of vendorIds) { - try { - // 협력업체 정보 조회 (이메일 포함) - const [vendorInfo] = await db - .select({ - id: vendors.id, - name: vendors.vendorName, - vendorCode: vendors.vendorCode, - email: vendors.email, // 협력업체 자체 이메일 추가 - representativeEmail: vendors.representativeEmail, // 협력업체 대표자 이메일 추가 - }) - .from(vendors) - .where(eq(vendors.id, vendorId)) - - if (!vendorInfo) { - failedVendors.push({ id: vendorId, reason: "협력업체 정보를 찾을 수 없습니다." }) - continue - } - - // 기존 협력업체 응답 레코드 찾기 - const existingResponse = await db - .select({ id: vendorResponses.id }) - .from(vendorResponses) - .where( - and( - eq(vendorResponses.rfqId, rfqId), - eq(vendorResponses.vendorId, vendorId) - ) - ) - .limit(1) - - if (existingResponse.length === 0) { - console.error(`협력업체 ID ${vendorId}에 대한 응답 레코드가 존재하지 않습니다.`) - failedVendors.push({ id: vendorId, reason: "협력업체 응답 레코드를 찾을 수 없습니다" }) - continue // 다음 벤더로 넘어감 - } - - // 1. CBE 평가 레코드 생성 - const [newCbeEvaluation] = await db - .insert(cbeEvaluations) - .values({ - rfqId, - vendorId, - evaluatedBy, - result: "PENDING", // 초기 상태는 PENDING으로 설정 - totalCost: 0, // 초기값은 0으로 설정 - currency: "USD", // 기본 통화 설정 - paymentTerms: validData.paymentTerms || null, - incoterms: validData.incoterms || null, - deliverySchedule: validData.deliverySchedule || null, - notes: validData.notes || null, - }) - .returning({ id: cbeEvaluations.id }) - - if (!newCbeEvaluation?.id) { - failedVendors.push({ id: vendorId, reason: "CBE 평가 생성 실패" }) - continue - } - - // 2. 상업 응답 레코드 생성 - const [newCbeResponse] = await db - .insert(vendorCommercialResponses) - .values({ - responseId: existingResponse[0].id, - responseStatus: "PENDING", - currency: "USD", - paymentTerms: validData.paymentTerms || null, - incoterms: validData.incoterms || null, - deliveryPeriod: validData.deliverySchedule || null, - }) - .returning({ id: vendorCommercialResponses.id }) - - if (!newCbeResponse?.id) { - failedVendors.push({ id: vendorId, reason: "상업 응답 생성 실패" }) - continue - } - - createdCbeIds.push(newCbeEvaluation.id) - - // 3. 첨부 파일이 있는 경우, 데이터베이스에 첨부 파일 레코드 생성 - if (hasFiles) { - for (let i = 0; i < attachments.length; i++) { - const attachment = attachments[i] - - await db.insert(rfqAttachments).values({ - rfqId, - vendorId, - fileName: attachment.filename, - filePath: `/${path.relative(path.join(process.cwd(), "public"), attachment.path)}`, // URL 경로를 위해 public 기준 상대 경로로 저장 - cbeId: newCbeEvaluation.id, - }) - } - } - - // 4. 협력업체 연락처 조회 - const contacts = await db - .select({ - contactName: vendorContacts.contactName, - contactEmail: vendorContacts.contactEmail, - isPrimary: vendorContacts.isPrimary, - }) - .from(vendorContacts) - .where(eq(vendorContacts.vendorId, vendorId)) - - // 5. 모든 이메일 주소 수집 및 중복 제거 - const allEmails = new Set<string>() - - // 연락처 이메일 추가 - contacts.forEach(contact => { - if (contact.contactEmail) { - allEmails.add(contact.contactEmail.trim().toLowerCase()) - } - }) - - // 협력업체 자체 이메일 추가 (있는 경우에만) - if (vendorInfo.email) { - allEmails.add(vendorInfo.email.trim().toLowerCase()) - } - - // 협력업체 대표자 이메일 추가 (있는 경우에만) - if (vendorInfo.representativeEmail) { - allEmails.add(vendorInfo.representativeEmail.trim().toLowerCase()) - } - - // 중복이 제거된 이메일 주소 배열로 변환 - const uniqueEmails = Array.from(allEmails) - - if (uniqueEmails.length === 0) { - console.warn(`협력업체 ID ${vendorId}에 등록된 이메일 주소가 없습니다.`) - } else { - console.log(`협력업체 ID ${vendorId}에 대해 ${uniqueEmails.length}개의 고유 이메일 주소로 알림을 전송합니다.`) - - // 이메일 발송에 필요한 공통 데이터 준비 - const emailData = { - rfqId, - cbeId: newCbeEvaluation.id, - vendorId, - rfqCode: rfqInfo.rfqCode, - projectCode: rfqInfo.projectCode, - projectName: rfqInfo.projectName, - dueDate: rfqInfo.dueDate, - description: rfqInfo.description, - vendorName: vendorInfo.name, - vendorCode: vendorInfo.vendorCode, - paymentTerms: validData.paymentTerms, - incoterms: validData.incoterms, - deliverySchedule: validData.deliverySchedule, - notes: validData.notes, - loginUrl: `http://${host}/en/partners/cbe-tech` - } - - // 각 고유 이메일 주소로 이메일 발송 - for (const email of uniqueEmails) { - try { - // 연락처 이름 찾기 (이메일과 일치하는 연락처가 있으면 사용, 없으면 '벤더명 담당자'로 대체) - const contact = contacts.find(c => - c.contactEmail && c.contactEmail.toLowerCase() === email.toLowerCase() - ) - const contactName = contact?.contactName || `${vendorInfo.name} 담당자` - - await sendEmail({ - to: email, - subject: `[RFQ ${rfqInfo.rfqCode}] 상업 입찰 평가 (CBE) 알림`, - template: "cbe-invitation", - context: { - language: "ko", // 또는 다국어 처리를 위한 설정 - contactName, - ...emailData, - }, - attachments: attachments, - }) - console.log(`이메일 전송 성공: ${email}`) - } catch (emailErr) { - console.error(`이메일 전송 실패 (${email}):`, emailErr) - } - } - } - - } catch (err) { - console.error(`협력업체 ID ${vendorId}의 CBE 생성 실패:`, err) - failedVendors.push({ id: vendorId, reason: "예기치 않은 오류" }) - } - } - - // 결과 반환 - if (createdCbeIds.length === 0) { - return { error: "어떤 벤더에 대해서도 CBE 평가를 생성하지 못했습니다." } - } - - return { - success: true, - cbeIds: createdCbeIds, - totalCreated: createdCbeIds.length, - totalFailed: failedVendors.length, - failedVendors: failedVendors.length > 0 ? failedVendors : undefined - } - - } catch (error) { - console.error("CBE 평가 생성 중 오류 발생:", error) - return { error: "예상치 못한 오류가 발생했습니다." } - } -} - -export async function getCBEbyVendorId(input: GetCBESchema, vendorId: number) { - // [1] 페이징 - const offset = ((input.page ?? 1) - 1) * (input.perPage ?? 10); - const limit = input.perPage ?? 10; - - // [2] 고급 필터 - const advancedWhere = filterColumns({ - table: vendorResponseCBEView, - filters: input.filters ?? [], - joinOperator: input.joinOperator ?? "and", - }); - - // [3] 글로벌 검색 - let globalWhere; - if (input.search) { - const s = `%${input.search}%`; - globalWhere = or( - sql`${vendorResponseCBEView.rfqCode} ILIKE ${s}`, - sql`${vendorResponseCBEView.projectCode} ILIKE ${s}`, - sql`${vendorResponseCBEView.projectName} ILIKE ${s}`, - sql`${vendorResponseCBEView.totalPrice}::text ILIKE ${s}` - ); - } - - // [4] DECLINED 상태 제외 (거절된 응답은 표시하지 않음) - // const notDeclined = ne(vendorResponseCBEView.responseStatus, "DECLINED"); - - // [5] 최종 where 조건 - const finalWhere = and( - eq(vendorResponseCBEView.vendorId, vendorId), // vendorId로 필터링 - isNotNull(vendorResponseCBEView.commercialCreatedAt), - // notDeclined, - advancedWhere ?? undefined, - globalWhere ?? undefined - ); - - // [6] 정렬 - const orderBy = input.sort?.length - ? input.sort.map((s) => { - // vendorResponseCBEView 컬럼 중 정렬 대상이 되는 것만 매핑 - const col = (vendorResponseCBEView as any)[s.id]; - return s.desc ? desc(col) : asc(col); - }) - : [desc(vendorResponseCBEView.rfqDueDate)]; // 기본 정렬은 RFQ 마감일 내림차순 - - // [7] 메인 SELECT - const [rows, total] = await db.transaction(async (tx) => { - const data = await tx - .select({ - // 기본 식별 정보 - responseId: vendorResponseCBEView.responseId, - vendorId: vendorResponseCBEView.vendorId, - rfqId: vendorResponseCBEView.rfqId, - - // 협력업체 정보 - vendorName: vendorResponseCBEView.vendorName, - vendorCode: vendorResponseCBEView.vendorCode, - vendorStatus: vendorResponseCBEView.vendorStatus, - - // RFQ 정보 - rfqCode: vendorResponseCBEView.rfqCode, - rfqDescription: vendorResponseCBEView.rfqDescription, - rfqDueDate: vendorResponseCBEView.rfqDueDate, - rfqStatus: vendorResponseCBEView.rfqStatus, - - // 프로젝트 정보 - projectId: vendorResponseCBEView.projectId, - projectCode: vendorResponseCBEView.projectCode, - projectName: vendorResponseCBEView.projectName, - - // 응답 상태 정보 - responseStatus: vendorResponseCBEView.responseStatus, - responseNotes: vendorResponseCBEView.notes, - respondedAt: vendorResponseCBEView.respondedAt, - respondedBy: vendorResponseCBEView.respondedBy, - - // 상업 응답 정보 - commercialResponseId: vendorResponseCBEView.commercialResponseId, - commercialResponseStatus: vendorResponseCBEView.commercialResponseStatus, - totalPrice: vendorResponseCBEView.totalPrice, - currency: vendorResponseCBEView.currency, - paymentTerms: vendorResponseCBEView.paymentTerms, - incoterms: vendorResponseCBEView.incoterms, - deliveryPeriod: vendorResponseCBEView.deliveryPeriod, - warrantyPeriod: vendorResponseCBEView.warrantyPeriod, - validityPeriod: vendorResponseCBEView.validityPeriod, - commercialNotes: vendorResponseCBEView.commercialNotes, - - // 첨부파일 카운트 - attachmentCount: vendorResponseCBEView.attachmentCount, - commercialAttachmentCount: vendorResponseCBEView.commercialAttachmentCount, - technicalAttachmentCount: vendorResponseCBEView.technicalAttachmentCount, - }) - .from(vendorResponseCBEView) - .where(finalWhere) - .orderBy(...orderBy) - .offset(offset) - .limit(limit); - - const [{ count }] = await tx - .select({ count: sql<number>`count(*)`.as("count") }) - .from(vendorResponseCBEView) - .where(finalWhere); - - return [data, Number(count)]; - }); - - if (!rows.length) { - return { data: [], pageCount: 0, total: 0 }; - } - - // [8] RFQ ID 목록 추출 - const distinctRfqIds = [...new Set(rows.map((r) => r.rfqId))]; - const distinctResponseIds = [...new Set(rows.map((r) => r.responseId))]; - const distinctCommercialResponseIds = [...new Set(rows.filter(r => r.commercialResponseId).map((r) => r.commercialResponseId!))]; - - // [9] CBE 평가 관련 코멘트 조회 - const commentsAll = await db - .select({ - id: rfqComments.id, - commentText: rfqComments.commentText, - rfqId: rfqComments.rfqId, - cbeId: rfqComments.cbeId, - createdAt: rfqComments.createdAt, - commentedBy: rfqComments.commentedBy, - }) - .from(rfqComments) - .innerJoin( - vendorResponses, - eq(vendorResponses.id, rfqComments.cbeId) - ) - .where( - and( - isNotNull(rfqComments.cbeId), - eq(rfqComments.vendorId, vendorId), - inArray(rfqComments.rfqId, distinctRfqIds) - ) - ); - - // rfqId별 코멘트 그룹화 - const commentsByRfqId = new Map<number, any[]>(); - for (const comment of commentsAll) { - const rfqId = comment.rfqId!; - if (!commentsByRfqId.has(rfqId)) { - commentsByRfqId.set(rfqId, []); - } - commentsByRfqId.get(rfqId)!.push({ - id: comment.id, - commentText: comment.commentText, - rfqId: comment.rfqId, - cbeId: comment.cbeId, - createdAt: comment.createdAt, - commentedBy: comment.commentedBy, - }); - } - - // [10] 첨부 파일 조회 - 일반 응답 첨부파일 - const responseAttachments = await db - .select({ - id: vendorResponseAttachments.id, - fileName: vendorResponseAttachments.fileName, - filePath: vendorResponseAttachments.filePath, - responseId: vendorResponseAttachments.responseId, - fileType: vendorResponseAttachments.fileType, - attachmentType: vendorResponseAttachments.attachmentType, - description: vendorResponseAttachments.description, - uploadedAt: vendorResponseAttachments.uploadedAt, - uploadedBy: vendorResponseAttachments.uploadedBy, - }) - .from(vendorResponseAttachments) - .where( - and( - inArray(vendorResponseAttachments.responseId, distinctResponseIds), - isNotNull(vendorResponseAttachments.responseId) - ) - ); - - // [11] 첨부 파일 조회 - 상업 응답 첨부파일 - const commercialResponseAttachments = await db - .select({ - id: vendorResponseAttachments.id, - fileName: vendorResponseAttachments.fileName, - filePath: vendorResponseAttachments.filePath, - commercialResponseId: vendorResponseAttachments.commercialResponseId, - fileType: vendorResponseAttachments.fileType, - attachmentType: vendorResponseAttachments.attachmentType, - description: vendorResponseAttachments.description, - uploadedAt: vendorResponseAttachments.uploadedAt, - uploadedBy: vendorResponseAttachments.uploadedBy, - }) - .from(vendorResponseAttachments) - .where( - and( - inArray(vendorResponseAttachments.commercialResponseId, distinctCommercialResponseIds), - isNotNull(vendorResponseAttachments.commercialResponseId) - ) - ); - - // [12] 첨부파일 그룹화 - // responseId별 첨부파일 맵 생성 - const filesByResponseId = new Map<number, any[]>(); - for (const file of responseAttachments) { - const responseId = file.responseId!; - if (!filesByResponseId.has(responseId)) { - filesByResponseId.set(responseId, []); - } - filesByResponseId.get(responseId)!.push({ - id: file.id, - fileName: file.fileName, - filePath: file.filePath, - fileType: file.fileType, - attachmentType: file.attachmentType, - description: file.description, - uploadedAt: file.uploadedAt, - uploadedBy: file.uploadedBy, - attachmentSource: 'response' - }); - } - - // commercialResponseId별 첨부파일 맵 생성 - const filesByCommercialResponseId = new Map<number, any[]>(); - for (const file of commercialResponseAttachments) { - const commercialResponseId = file.commercialResponseId!; - if (!filesByCommercialResponseId.has(commercialResponseId)) { - filesByCommercialResponseId.set(commercialResponseId, []); - } - filesByCommercialResponseId.get(commercialResponseId)!.push({ - id: file.id, - fileName: file.fileName, - filePath: file.filePath, - fileType: file.fileType, - attachmentType: file.attachmentType, - description: file.description, - uploadedAt: file.uploadedAt, - uploadedBy: file.uploadedBy, - attachmentSource: 'commercial' - }); - } - - // [13] 최종 데이터 병합 - const final = rows.map((row) => { - // 해당 응답의 모든 첨부파일 가져오기 - const responseFiles = filesByResponseId.get(row.responseId) || []; - const commercialFiles = row.commercialResponseId - ? filesByCommercialResponseId.get(row.commercialResponseId) || [] - : []; - - // 모든 첨부파일 병합 - const allFiles = [...responseFiles, ...commercialFiles]; - - return { - ...row, - rfqDueDate: row.rfqDueDate ? new Date(row.rfqDueDate) : null, - respondedAt: row.respondedAt ? new Date(row.respondedAt) : null, - comments: commentsByRfqId.get(row.rfqId) || [], - files: allFiles, - }; - }); - - const pageCount = Math.ceil(total / limit); - return { - data: final, - pageCount, - total - }; -} - -export async function fetchCbeFiles(vendorId: number, rfqId: number) { - try { - // 1. 먼저 해당 RFQ와 벤더에 해당하는 CBE 평가 레코드를 찾습니다. - const cbeEval = await db - .select({ id: cbeEvaluations.id }) - .from(cbeEvaluations) - .where( - and( - eq(cbeEvaluations.rfqId, rfqId), - eq(cbeEvaluations.vendorId, vendorId) - ) - ) - .limit(1) - - if (!cbeEval.length) { - return { - files: [], - error: "해당 RFQ와 벤더에 대한 CBE 평가를 찾을 수 없습니다." - } - } - - const cbeId = cbeEval[0].id - - // 2. 관련 첨부 파일을 조회합니다. - // - commentId와 evaluationId는 null이어야 함 - // - rfqId와 vendorId가 일치해야 함 - // - cbeId가 위에서 찾은 CBE 평가 ID와 일치해야 함 - const files = await db - .select({ - id: rfqAttachments.id, - fileName: rfqAttachments.fileName, - filePath: rfqAttachments.filePath, - createdAt: rfqAttachments.createdAt - }) - .from(rfqAttachments) - .where( - and( - eq(rfqAttachments.rfqId, rfqId), - eq(rfqAttachments.vendorId, vendorId), - eq(rfqAttachments.cbeId, cbeId), - isNull(rfqAttachments.commentId), - isNull(rfqAttachments.evaluationId) - ) - ) - .orderBy(rfqAttachments.createdAt) - - return { - files, - cbeId - } - } catch (error) { - console.error("CBE 파일 조회 중 오류 발생:", error) - return { - files: [], - error: "CBE 파일을 가져오는 중 오류가 발생했습니다." - } - } -} - -export async function getAllCBE(input: GetCBESchema) { - // [1] 페이징 - const offset = ((input.page ?? 1) - 1) * (input.perPage ?? 10); - const limit = input.perPage ?? 10; - - // [2] 고급 필터 - const advancedWhere = filterColumns({ - table: vendorResponseCBEView, - filters: input.filters ?? [], - joinOperator: input.joinOperator ?? "and", - }); - - // [3] 글로벌 검색 - let globalWhere; - if (input.search) { - const s = `%${input.search}%`; - globalWhere = or( - sql`${vendorResponseCBEView.vendorName} ILIKE ${s}`, - sql`${vendorResponseCBEView.vendorCode} ILIKE ${s}`, - sql`${vendorResponseCBEView.rfqCode} ILIKE ${s}`, - sql`${vendorResponseCBEView.projectCode} ILIKE ${s}`, - sql`${vendorResponseCBEView.projectName} ILIKE ${s}`, - sql`${vendorResponseCBEView.totalPrice}::text ILIKE ${s}` - ); - } - - // [4] DECLINED 상태 제외 (거절된 업체는 표시하지 않음) - const notDeclined = ne(vendorResponseCBEView.responseStatus, "DECLINED"); - - // [6] 최종 where 조건 - const finalWhere = and( - notDeclined, - advancedWhere ?? undefined, - globalWhere ?? undefined, - ); - - // [7] 정렬 - const orderBy = input.sort?.length - ? input.sort.map((s) => { - // vendorResponseCBEView 컬럼 중 정렬 대상이 되는 것만 매핑 - const col = (vendorResponseCBEView as any)[s.id]; - return s.desc ? desc(col) : asc(col); - }) - : [desc(vendorResponseCBEView.rfqId), asc(vendorResponseCBEView.vendorName)]; // 기본 정렬은 최신 RFQ 먼저, 그 다음 벤더명 - - // [8] 메인 SELECT - const [rows, total] = await db.transaction(async (tx) => { - const data = await tx - .select({ - // 기본 식별 정보 - responseId: vendorResponseCBEView.responseId, - vendorId: vendorResponseCBEView.vendorId, - rfqId: vendorResponseCBEView.rfqId, - - // 협력업체 정보 - vendorName: vendorResponseCBEView.vendorName, - vendorCode: vendorResponseCBEView.vendorCode, - vendorStatus: vendorResponseCBEView.vendorStatus, - - // RFQ 정보 - rfqCode: vendorResponseCBEView.rfqCode, - rfqDescription: vendorResponseCBEView.rfqDescription, - rfqDueDate: vendorResponseCBEView.rfqDueDate, - rfqStatus: vendorResponseCBEView.rfqStatus, - - // 프로젝트 정보 - projectId: vendorResponseCBEView.projectId, - projectCode: vendorResponseCBEView.projectCode, - projectName: vendorResponseCBEView.projectName, - - // 응답 상태 정보 - responseStatus: vendorResponseCBEView.responseStatus, - responseNotes: vendorResponseCBEView.notes, - respondedAt: vendorResponseCBEView.respondedAt, - respondedBy: vendorResponseCBEView.respondedBy, - - // 상업 응답 정보 - commercialResponseId: vendorResponseCBEView.commercialResponseId, - commercialResponseStatus: vendorResponseCBEView.commercialResponseStatus, - totalPrice: vendorResponseCBEView.totalPrice, - currency: vendorResponseCBEView.currency, - paymentTerms: vendorResponseCBEView.paymentTerms, - incoterms: vendorResponseCBEView.incoterms, - deliveryPeriod: vendorResponseCBEView.deliveryPeriod, - warrantyPeriod: vendorResponseCBEView.warrantyPeriod, - validityPeriod: vendorResponseCBEView.validityPeriod, - commercialNotes: vendorResponseCBEView.commercialNotes, - - // 첨부파일 카운트 - attachmentCount: vendorResponseCBEView.attachmentCount, - commercialAttachmentCount: vendorResponseCBEView.commercialAttachmentCount, - technicalAttachmentCount: vendorResponseCBEView.technicalAttachmentCount, - }) - .from(vendorResponseCBEView) - .where(finalWhere) - .orderBy(...orderBy) - .offset(offset) - .limit(limit); - - const [{ count }] = await tx - .select({ count: sql<number>`count(*)`.as("count") }) - .from(vendorResponseCBEView) - .where(finalWhere); - - return [data, Number(count)]; - }); - - if (!rows.length) { - return { data: [], pageCount: 0, total: 0 }; - } - - // [9] 고유한 rfqIds와 vendorIds 추출 - null 필터링 - const distinctVendorIds = [...new Set(rows.map((r) => r.vendorId).filter(Boolean))] as number[]; - const distinctRfqIds = [...new Set(rows.map((r) => r.rfqId).filter(Boolean))] as number[]; - const distinctResponseIds = [...new Set(rows.map((r) => r.responseId).filter(Boolean))] as number[]; - const distinctCommercialResponseIds = [...new Set(rows.filter(r => r.commercialResponseId).map((r) => r.commercialResponseId!))]; - - // [10] CBE 평가 관련 코멘트 조회 - const commentsConditions = [isNotNull(rfqComments.cbeId)]; - - // 배열이 비어있지 않을 때만 조건 추가 - if (distinctRfqIds.length > 0) { - commentsConditions.push(inArray(rfqComments.rfqId, distinctRfqIds)); - } - - if (distinctVendorIds.length > 0) { - commentsConditions.push(inArray(rfqComments.vendorId, distinctVendorIds)); - } - - const commentsAll = await db - .select({ - id: rfqComments.id, - commentText: rfqComments.commentText, - vendorId: rfqComments.vendorId, - rfqId: rfqComments.rfqId, - cbeId: rfqComments.cbeId, - createdAt: rfqComments.createdAt, - commentedBy: rfqComments.commentedBy, - }) - .from(rfqComments) - .innerJoin( - vendorResponses, - eq(vendorResponses.id, rfqComments.cbeId) - ) - .where(and(...commentsConditions)); - - // [11] 복합 키(rfqId-vendorId)별 코멘트 그룹화 - const commentsByCompositeKey = new Map<string, any[]>(); - for (const comment of commentsAll) { - if (!comment.rfqId || !comment.vendorId) continue; - - const compositeKey = `${comment.rfqId}-${comment.vendorId}`; - if (!commentsByCompositeKey.has(compositeKey)) { - commentsByCompositeKey.set(compositeKey, []); - } - commentsByCompositeKey.get(compositeKey)!.push({ - id: comment.id, - commentText: comment.commentText, - vendorId: comment.vendorId, - cbeId: comment.cbeId, - createdAt: comment.createdAt, - commentedBy: comment.commentedBy, - }); - } - - // [12] 첨부 파일 조회 - 일반 응답 첨부파일 - const responseAttachments = await db - .select({ - id: vendorResponseAttachments.id, - fileName: vendorResponseAttachments.fileName, - filePath: vendorResponseAttachments.filePath, - responseId: vendorResponseAttachments.responseId, - fileType: vendorResponseAttachments.fileType, - attachmentType: vendorResponseAttachments.attachmentType, - description: vendorResponseAttachments.description, - uploadedAt: vendorResponseAttachments.uploadedAt, - uploadedBy: vendorResponseAttachments.uploadedBy, - }) - .from(vendorResponseAttachments) - .where( - and( - inArray(vendorResponseAttachments.responseId, distinctResponseIds), - isNotNull(vendorResponseAttachments.responseId) - ) - ); - - // [13] 첨부 파일 조회 - 상업 응답 첨부파일 - const commercialResponseAttachments = await db - .select({ - id: vendorResponseAttachments.id, - fileName: vendorResponseAttachments.fileName, - filePath: vendorResponseAttachments.filePath, - commercialResponseId: vendorResponseAttachments.commercialResponseId, - fileType: vendorResponseAttachments.fileType, - attachmentType: vendorResponseAttachments.attachmentType, - description: vendorResponseAttachments.description, - uploadedAt: vendorResponseAttachments.uploadedAt, - uploadedBy: vendorResponseAttachments.uploadedBy, - }) - .from(vendorResponseAttachments) - .where( - and( - inArray(vendorResponseAttachments.commercialResponseId, distinctCommercialResponseIds), - isNotNull(vendorResponseAttachments.commercialResponseId) - ) - ); - - // [14] 첨부파일 그룹화 - // responseId별 첨부파일 맵 생성 - const filesByResponseId = new Map<number, any[]>(); - for (const file of responseAttachments) { - const responseId = file.responseId!; - if (!filesByResponseId.has(responseId)) { - filesByResponseId.set(responseId, []); - } - filesByResponseId.get(responseId)!.push({ - id: file.id, - fileName: file.fileName, - filePath: file.filePath, - fileType: file.fileType, - attachmentType: file.attachmentType, - description: file.description, - uploadedAt: file.uploadedAt, - uploadedBy: file.uploadedBy, - attachmentSource: 'response' - }); - } - - // commercialResponseId별 첨부파일 맵 생성 - const filesByCommercialResponseId = new Map<number, any[]>(); - for (const file of commercialResponseAttachments) { - const commercialResponseId = file.commercialResponseId!; - if (!filesByCommercialResponseId.has(commercialResponseId)) { - filesByCommercialResponseId.set(commercialResponseId, []); - } - filesByCommercialResponseId.get(commercialResponseId)!.push({ - id: file.id, - fileName: file.fileName, - filePath: file.filePath, - fileType: file.fileType, - attachmentType: file.attachmentType, - description: file.description, - uploadedAt: file.uploadedAt, - uploadedBy: file.uploadedBy, - attachmentSource: 'commercial' - }); - } - - // [15] 복합 키(rfqId-vendorId)별 첨부파일 맵 생성 - const filesByCompositeKey = new Map<string, any[]>(); - - // responseId -> rfqId-vendorId 매핑 생성 - const responseIdToCompositeKey = new Map<number, string>(); - for (const row of rows) { - if (row.responseId) { - responseIdToCompositeKey.set(row.responseId, `${row.rfqId}-${row.vendorId}`); - } - if (row.commercialResponseId) { - responseIdToCompositeKey.set(row.commercialResponseId, `${row.rfqId}-${row.vendorId}`); - } - } - - // responseId별 첨부파일을 복합 키별로 그룹화 - for (const [responseId, files] of filesByResponseId.entries()) { - const compositeKey = responseIdToCompositeKey.get(responseId); - if (compositeKey) { - if (!filesByCompositeKey.has(compositeKey)) { - filesByCompositeKey.set(compositeKey, []); - } - filesByCompositeKey.get(compositeKey)!.push(...files); - } - } - - // commercialResponseId별 첨부파일을 복합 키별로 그룹화 - for (const [commercialResponseId, files] of filesByCommercialResponseId.entries()) { - const compositeKey = responseIdToCompositeKey.get(commercialResponseId); - if (compositeKey) { - if (!filesByCompositeKey.has(compositeKey)) { - filesByCompositeKey.set(compositeKey, []); - } - filesByCompositeKey.get(compositeKey)!.push(...files); - } - } - - // [16] 최종 데이터 병합 - const final = rows.map((row) => { - const compositeKey = `${row.rfqId}-${row.vendorId}`; - - return { - ...row, - rfqDueDate: row.rfqDueDate ? new Date(row.rfqDueDate) : null, - respondedAt: row.respondedAt ? new Date(row.respondedAt) : null, - comments: commentsByCompositeKey.get(compositeKey) || [], - files: filesByCompositeKey.get(compositeKey) || [], - }; - }); - - const pageCount = Math.ceil(total / limit); - return { - data: final, - pageCount, - total - }; -}
\ No newline at end of file diff --git a/lib/rfqs-tech/table/ItemsDialog.tsx b/lib/rfqs-tech/table/ItemsDialog.tsx deleted file mode 100644 index 022d6430..00000000 --- a/lib/rfqs-tech/table/ItemsDialog.tsx +++ /dev/null @@ -1,754 +0,0 @@ -"use client" - -import * as React from "react" -import { useForm, useFieldArray, useWatch } from "react-hook-form" -import { zodResolver } from "@hookform/resolvers/zod" -import { z } from "zod" - -import { - Dialog, - DialogContent, - DialogHeader, - DialogTitle, - DialogDescription, - DialogFooter, -} from "@/components/ui/dialog" -import { Button } from "@/components/ui/button" -import { Input } from "@/components/ui/input" -import { - Form, - FormControl, - FormField, - FormItem, - FormLabel, - FormMessage -} from "@/components/ui/form" -import { Popover, PopoverTrigger, PopoverContent } from "@/components/ui/popover" -import { - Command, - CommandInput, - CommandList, - CommandItem, - CommandGroup, - CommandEmpty -} from "@/components/ui/command" -import { Check, ChevronsUpDown, Plus, Trash2, Save, X, AlertCircle, Eye } from "lucide-react" -import { toast } from "sonner" -import { - AlertDialog, - AlertDialogAction, - AlertDialogCancel, - AlertDialogContent, - AlertDialogDescription, - AlertDialogFooter, - AlertDialogHeader, - AlertDialogTitle, -} from "@/components/ui/alert-dialog" -import { - Tooltip, - TooltipContent, - TooltipProvider, - TooltipTrigger, -} from "@/components/ui/tooltip" -import { Badge } from "@/components/ui/badge" - -import { createRfqItem, deleteRfqItem } from "../service" -import { RfqWithItemCount } from "@/db/schema/rfq" - -// Zod 스키마 - 수량은 string으로 받아서 나중에 변환 -const itemSchema = z.object({ - id: z.number().optional(), - itemCode: z.string().nonempty({ message: "아이템 코드를 선택해주세요" }), - description: z.string().optional(), - quantity: z.coerce.number().min(1, { message: "최소 수량은 1입니다" }).default(1), - uom: z.string().default("each"), -}); - -const itemsFormSchema = z.object({ - rfqId: z.number().int(), - items: z.array(itemSchema).min(1, { message: "최소 1개 이상의 아이템을 추가해주세요" }), -}); - -type ItemsFormSchema = z.infer<typeof itemsFormSchema>; - -interface RfqsItemsDialogProps { - open: boolean; - onOpenChange: (open: boolean) => void; - rfq: RfqWithItemCount | null; - defaultItems?: { - id?: number; - itemCode: string; - quantity?: number | null; - description?: string | null; - uom?: string | null; - }[]; - itemsList: { - code: string | null; - itemList?: string; - subItemList?: string; - }[]; -} - -export function RfqsItemsDialog({ - open, - onOpenChange, - rfq, - defaultItems = [], - itemsList, -}: RfqsItemsDialogProps) { - const rfqId = rfq?.rfqId ?? 0; - console.log(itemsList) - - // 편집 가능 여부 확인 - DRAFT 상태일 때만 편집 가능 - const isEditable = rfq?.status === "DRAFT"; - - // 초기 아이템 ID 목록을 추적하기 위한 상태 추가 - const [initialItemIds, setInitialItemIds] = React.useState<(number | undefined)[]>([]); - - // 삭제된 아이템 ID를 저장하는 상태 추가 - const [deletedItemIds, setDeletedItemIds] = React.useState<number[]>([]); - - // 1) form - const form = useForm<ItemsFormSchema>({ - resolver: zodResolver(itemsFormSchema), - defaultValues: { - rfqId, - items: defaultItems.length > 0 ? defaultItems.map((it) => ({ - id: it.id, - quantity: it.quantity ?? 1, - uom: it.uom ?? "each", - itemCode: it.itemCode ?? "", - description: it.description ?? "", - })) : [{ itemCode: "", description: "", quantity: 1, uom: "each" }], - }, - mode: "onChange", // 입력 필드가 변경될 때마다 유효성 검사 - }); - - // 다이얼로그가 열릴 때마다 폼 초기화 및 초기 아이템 ID 저장 - React.useEffect(() => { - if (open) { - const initialItems = defaultItems.length > 0 - ? defaultItems.map((it) => ({ - id: it.id, - quantity: it.quantity ?? 1, - uom: it.uom ?? "each", - itemCode: it.itemCode ?? "", - description: it.description ?? "", - })) - : [{ itemCode: "", description: "", quantity: 1, uom: "each" }]; - - form.reset({ - rfqId, - items: initialItems, - }); - - // 초기 아이템 ID 목록 저장 - setInitialItemIds(defaultItems.map(item => item.id)); - - // 삭제된 아이템 목록 초기화 - setDeletedItemIds([]); - setHasUnsavedChanges(false); - } - }, [open, defaultItems, rfqId, form]); - - // 새로운 요소에 대한 ref 배열 - const inputRefs = React.useRef<Array<HTMLButtonElement | null>>([]); - const [isSubmitting, setIsSubmitting] = React.useState(false); - const [hasUnsavedChanges, setHasUnsavedChanges] = React.useState(false); - const [isExitDialogOpen, setIsExitDialogOpen] = React.useState(false); - - // 폼 변경 감지 - 편집 가능한 경우에만 변경 감지 - React.useEffect(() => { - if (!isEditable) return; - - const subscription = form.watch(() => { - setHasUnsavedChanges(true); - }); - return () => subscription.unsubscribe(); - }, [form, isEditable]); - - // 2) field array - const { fields, append, remove } = useFieldArray({ - control: form.control, - name: "items", - }); - - // 3) watch items array - const watchItems = form.watch("items"); - - // 4) Add item row with auto-focus - function handleAddItem() { - if (!isEditable) return; - - // 명시적으로 숫자 타입으로 지정 - append({ - itemCode: "", - description: "", - quantity: 1, - uom: "each" - }); - setHasUnsavedChanges(true); - - // 다음 렌더링 사이클에서 새로 추가된 항목에 포커스 - setTimeout(() => { - const newIndex = fields.length; - const button = inputRefs.current[newIndex]; - if (button) { - button.click(); - } - }, 100); - } - - // 항목 직접 삭제 - 기존 ID가 있을 경우 삭제 목록에 추가 - const handleRemoveItem = (index: number) => { - if (!isEditable) return; - - const itemToRemove = form.getValues().items[index]; - - // 기존 ID가 있는 아이템이라면 삭제 목록에 추가 - if (itemToRemove.id !== undefined) { - setDeletedItemIds(prev => [...prev, itemToRemove.id as number]); - } - - remove(index); - setHasUnsavedChanges(true); - - // 포커스 처리: 다음 항목이 있으면 다음 항목으로, 없으면 마지막 항목으로 - setTimeout(() => { - const nextIndex = Math.min(index, fields.length - 1); - if (nextIndex >= 0 && inputRefs.current[nextIndex]) { - inputRefs.current[nextIndex]?.click(); - } - }, 50); - }; - - // 다이얼로그 닫기 전 확인 - const handleDialogClose = (open: boolean) => { - if (!open && hasUnsavedChanges && isEditable) { - setIsExitDialogOpen(true); - } else { - onOpenChange(open); - } - }; - - // 필드 포커스 유틸리티 함수 - const focusField = (selector: string) => { - if (!isEditable) return; - - setTimeout(() => { - const element = document.querySelector(selector) as HTMLInputElement | null; - if (element) { - element.focus(); - } - }, 10); - }; - - // 5) Submit - 업데이트된 제출 로직 (생성/수정 + 삭제 처리) - async function onSubmit(data: ItemsFormSchema) { - if (!isEditable) return; - - try { - setIsSubmitting(true); - - // 각 아이템이 유효한지 확인 - const anyInvalidItems = data.items.some(item => !item.itemCode || item.quantity < 1); - - if (anyInvalidItems) { - toast.error("유효하지 않은 아이템이 있습니다. 모든 필드를 확인해주세요."); - setIsSubmitting(false); - return; - } - - // 1. 삭제 처리 - 삭제된 아이템 ID가 있으면 삭제 요청 - const deletePromises = deletedItemIds.map(id => - deleteRfqItem({ - id: id, - rfqId: rfqId, - }) - ); - - // 2. 생성/수정 처리 - 폼에 남아있는 아이템들 - const upsertPromises = data.items.map((item) => - createRfqItem({ - rfqId: rfqId, - itemCode: item.itemCode, - description: item.description, - // 명시적으로 숫자로 변환 - quantity: Number(item.quantity), - uom: item.uom, - id: item.id // 기존 ID가 있으면 업데이트, 없으면 생성 - }) - ); - - // 모든 요청 병렬 처리 - await Promise.all([...deletePromises, ...upsertPromises]); - - toast.success("RFQ 아이템이 성공적으로 저장되었습니다!"); - setHasUnsavedChanges(false); - onOpenChange(false); - } catch (err) { - toast.error(`오류가 발생했습니다: ${String(err)}`); - } finally { - setIsSubmitting(false); - } - } - - // 단축키 처리 - 편집 가능한 경우에만 단축키 활성화 - React.useEffect(() => { - if (!isEditable) return; - - const handleKeyDown = (e: KeyboardEvent) => { - // Alt+N: 새 항목 추가 - if (e.altKey && e.key === 'n') { - e.preventDefault(); - handleAddItem(); - } - // Ctrl+S: 저장 - if ((e.ctrlKey || e.metaKey) && e.key === 's') { - e.preventDefault(); - form.handleSubmit(onSubmit)(); - } - // Esc: 포커스된 팝오버 닫기 - if (e.key === 'Escape') { - document.querySelectorAll('[role="combobox"][aria-expanded="true"]').forEach( - (el) => (el as HTMLButtonElement).click() - ); - } - }; - - window.addEventListener('keydown', handleKeyDown); - return () => window.removeEventListener('keydown', handleKeyDown); - }, [form, isEditable]); - - return ( - <> - <Dialog open={open} onOpenChange={handleDialogClose}> - <DialogContent className="max-w-none w-[1200px]"> - <DialogHeader> - <DialogTitle className="flex items-center gap-2"> - {isEditable ? "RFQ 아이템 관리" : "RFQ 아이템 조회"} - <Badge variant="outline" className="ml-2"> - {rfq?.rfqCode || `RFQ #${rfqId}`} - </Badge> - - {rfq?.status && ( - <Badge - variant={rfq.status === "DRAFT" ? "outline" : "secondary"} - className="ml-1" - > - {rfq.status} - </Badge> - )} - </DialogTitle> - <DialogDescription> - {isEditable - ? (rfq?.description || '아이템을 각 행에 하나씩 추가할 수 있습니다.') - : '드래프트 상태가 아닌 RFQ는 아이템을 편집할 수 없습니다.'} - </DialogDescription> - </DialogHeader> - <div className="overflow-x-auto w-full"> - <Form {...form}> - <form onSubmit={form.handleSubmit(onSubmit)}> - <div className="space-y-4"> - {/* 헤더 행 (라벨) */} - <div className="flex items-center gap-2 border-b pb-2 font-medium text-sm"> - <div className="w-[250px] pl-3">아이템</div> - <div className="w-[400px] pl-2">설명</div> - <div className="w-[80px] pl-2 text-center">수량</div> - <div className="w-[80px] pl-2 text-center">단위</div> - {isEditable && <div className="w-[42px]"></div>} - </div> - - {/* 아이템 행들 */} - <div className="max-h-[50vh] overflow-y-auto pr-1 space-y-3"> - {fields.map((field, index) => { - // 현재 row의 itemCode - const codeValue = watchItems[index]?.itemCode || ""; - // "이미" 사용된 코드를 모두 구함 - const usedCodes = watchItems - .map((it, i) => i === index ? null : it.itemCode) - .filter(Boolean) as string[]; - - // itemsList에서 "현재 선택한 code"만 예외적으로 허용하고, - // 다른 행에서 이미 사용한 code는 제거 - const filteredItems = (itemsList || []) - .filter((it) => { - if (!it.code) return false; - if (it.code === codeValue) return true; - return !usedCodes.includes(it.code); - }); - - // 선택된 아이템 찾기 - const selected = filteredItems.find(it => it.code === codeValue); - - return ( - <div key={field.id} className="flex items-center gap-2 group hover:bg-gray-50 p-1 rounded-md transition-colors"> - {/* -- itemCode + Popover(Select) -- */} - {isEditable ? ( - <FormField - control={form.control} - name={`items.${index}.itemCode`} - render={({ field }) => { - const [popoverOpen, setPopoverOpen] = React.useState(false); - const selected = filteredItems.find(it => it.code === field.value); - - return ( - <FormItem className="flex items-center gap-2 w-[250px]" style={{width:250}}> - <FormControl> - <Popover open={popoverOpen} onOpenChange={setPopoverOpen}> - <PopoverTrigger asChild> - <Button - ref={el => { - inputRefs.current[index] = el; - }} - variant="outline" - role="combobox" - aria-expanded={popoverOpen} - className="flex items-center" - data-error={!!form.formState.errors.items?.[index]?.itemCode} - data-state={selected ? "filled" : "empty"} - style={{width:250}} - > - <div className="flex-1 overflow-hidden mr-2 text-left"> - <span className="block truncate" style={{width:200}}> - {selected ? ( - <> - <div>{selected.code}</div> - {(selected.itemList || selected.subItemList) && ( - <div className="text-xs text-muted-foreground"> - {selected.itemList} - {selected.subItemList && ` / ${selected.subItemList}`} - </div> - )} - </> - ) : "아이템 선택..."} - </span> - </div> - <ChevronsUpDown className="h-4 w-4 flex-shrink-0 opacity-50" /> - </Button> - </PopoverTrigger> - <PopoverContent className="w-[400px] p-0"> - <Command> - <CommandInput placeholder="아이템 검색..." className="h-9" autoFocus /> - <CommandList> - <CommandEmpty>아이템을 찾을 수 없습니다.</CommandEmpty> - <CommandGroup> - {filteredItems.map((it) => ( - <CommandItem - key={it.code} - value={`${it.code} ${it.itemList || ''} ${it.subItemList || ''}`} - onSelect={() => { - field.onChange(it.code); - setPopoverOpen(false); - focusField(`input[name="items.${index}.description"]`); - }} - > - <div className="flex-1 overflow-hidden"> - <div className="font-medium">{it.code}</div> - {(it.itemList || it.subItemList) && ( - <div className="text-xs text-muted-foreground"> - {it.itemList} - {it.subItemList && ` / ${it.subItemList}`} - </div> - )} - </div> - <Check - className={ - "ml-auto h-4 w-4" + - (it.code === field.value ? " opacity-100" : " opacity-0") - } - /> - </CommandItem> - ))} - </CommandGroup> - </CommandList> - </Command> - </PopoverContent> - </Popover> - </FormControl> - {form.formState.errors.items?.[index]?.itemCode && ( - <AlertCircle className="h-4 w-4 text-destructive" /> - )} - </FormItem> - ); - }} - /> - ) : ( - <div className="flex items-center w-[250px] pl-3"> - {selected ? `${selected.code}` : codeValue} - </div> - )} - - {/* ID 필드 추가 (숨김) */} - <FormField - control={form.control} - name={`items.${index}.id`} - render={({ field }) => ( - <input type="hidden" {...field} /> - )} - /> - - {/* description */} - {isEditable ? ( - <FormField - control={form.control} - name={`items.${index}.description`} - render={({ field }) => ( - <FormItem className="w-[400px]"> - <FormControl> - <Input - className="w-full" - placeholder="아이템 상세 정보" - {...field} - onKeyDown={(e) => { - if (e.key === 'Enter') { - e.preventDefault(); - focusField(`input[name="items.${index}.quantity"]`); - } - }} - /> - </FormControl> - <FormMessage /> - </FormItem> - )} - /> - ) : ( - <div className="w-[400px] pl-2"> - {watchItems[index]?.description || ""} - </div> - )} - - {/* quantity */} - {isEditable ? ( - <FormField - control={form.control} - name={`items.${index}.quantity`} - render={({ field }) => ( - <FormItem className="w-[80px] relative"> - <FormControl> - <Input - type="number" - className="w-full text-center" - min="1" - {...field} - // 값 변경 핸들러 개선 - onChange={(e) => { - const value = e.target.value === '' ? 1 : parseInt(e.target.value, 10); - field.onChange(isNaN(value) ? 1 : value); - }} - // 최소값 보장 (빈 문자열 방지) - onBlur={(e) => { - if (e.target.value === '' || parseInt(e.target.value, 10) < 1) { - field.onChange(1); - } - }} - onKeyDown={(e) => { - if (e.key === 'Enter') { - e.preventDefault(); - focusField(`input[name="items.${index}.uom"]`); - } - }} - /> - </FormControl> - {form.formState.errors.items?.[index]?.quantity && ( - <AlertCircle className="h-4 w-4 text-destructive absolute right-2 top-2" /> - )} - </FormItem> - )} - /> - ) : ( - <div className="w-[80px] text-center"> - {watchItems[index]?.quantity} - </div> - )} - - {/* uom */} - {isEditable ? ( - <FormField - control={form.control} - name={`items.${index}.uom`} - render={({ field }) => ( - <FormItem className="w-[80px]"> - <FormControl> - <Input - placeholder="each" - className="w-full text-center" - {...field} - onKeyDown={(e) => { - if (e.key === 'Enter') { - e.preventDefault(); - // 마지막 행이면 새로운 행 추가 - if (index === fields.length - 1) { - handleAddItem(); - } else { - // 아니면 다음 행의 아이템 선택으로 이동 - const button = inputRefs.current[index + 1]; - if (button) { - setTimeout(() => button.click(), 10); - } - } - } - }} - /> - </FormControl> - <FormMessage /> - </FormItem> - )} - /> - ) : ( - <div className="w-[80px] text-center"> - {watchItems[index]?.uom || "each"} - </div> - )} - - {/* remove row - 편집 모드에서만 표시 */} - {isEditable && ( - <TooltipProvider> - <Tooltip> - <TooltipTrigger asChild> - <Button - type="button" - variant="ghost" - size="icon" - onClick={() => handleRemoveItem(index)} - className="group-hover:opacity-100 transition-opacity" - aria-label="아이템 삭제" - > - <Trash2 className="h-4 w-4 text-destructive" /> - </Button> - </TooltipTrigger> - <TooltipContent> - <p>아이템 삭제</p> - </TooltipContent> - </Tooltip> - </TooltipProvider> - )} - </div> - ); - })} - </div> - - <div className="flex justify-between items-center pt-2 border-t"> - <div className="flex items-center gap-2"> - {isEditable ? ( - <> - <TooltipProvider> - <Tooltip> - <TooltipTrigger asChild> - <Button type="button" variant="outline" onClick={handleAddItem} className="gap-1"> - <Plus className="h-4 w-4" /> - 아이템 추가 - </Button> - </TooltipTrigger> - <TooltipContent side="bottom"> - <p>단축키: Alt+N</p> - </TooltipContent> - </Tooltip> - </TooltipProvider> - <span className="text-sm text-muted-foreground"> - {fields.length}개 아이템 - </span> - {deletedItemIds.length > 0 && ( - <span className="text-sm text-destructive"> - ({deletedItemIds.length}개 아이템 삭제 예정) - </span> - )} - </> - ) : ( - <span className="text-sm text-muted-foreground"> - {fields.length}개 아이템 - </span> - )} - </div> - - {isEditable && ( - <div className="text-xs text-muted-foreground"> - <span className="inline-flex items-center gap-1 mr-2"> - <kbd className="px-1 py-0.5 bg-gray-100 rounded text-xs">Tab</kbd> - <span>필드 간 이동</span> - </span> - <span className="inline-flex items-center gap-1"> - <kbd className="px-1 py-0.5 bg-gray-100 rounded text-xs">Enter</kbd> - <span>다음 필드로 이동</span> - </span> - </div> - )} - </div> - </div> - - <DialogFooter className="mt-6 gap-2"> - {isEditable ? ( - <> - <TooltipProvider> - <Tooltip> - <TooltipTrigger asChild> - <Button type="button" variant="outline" onClick={() => handleDialogClose(false)}> - <X className="mr-2 h-4 w-4" /> - 취소 - </Button> - </TooltipTrigger> - <TooltipContent>변경사항을 저장하지 않고 나가기</TooltipContent> - </Tooltip> - </TooltipProvider> - - <TooltipProvider> - <Tooltip> - <TooltipTrigger asChild> - <Button - type="submit" - disabled={isSubmitting || (!form.formState.isDirty && deletedItemIds.length === 0) || !form.formState.isValid} - > - {isSubmitting ? ( - <>처리 중...</> - ) : ( - <> - <Save className="mr-2 h-4 w-4" /> - 저장 - </> - )} - </Button> - </TooltipTrigger> - <TooltipContent> - <p>단축키: Ctrl+S</p> - </TooltipContent> - </Tooltip> - </TooltipProvider> - </> - ) : ( - <Button type="button" variant="outline" onClick={() => onOpenChange(false)}> - <X className="mr-2 h-4 w-4" /> - 닫기 - </Button> - )} - </DialogFooter> - </form> - </Form> - </div> - </DialogContent> - </Dialog> - - {/* 저장하지 않고 나가기 확인 다이얼로그 - 편집 모드에서만 활성화 */} - {isEditable && ( - <AlertDialog open={isExitDialogOpen} onOpenChange={setIsExitDialogOpen}> - <AlertDialogContent> - <AlertDialogHeader> - <AlertDialogTitle>저장되지 않은 변경사항</AlertDialogTitle> - <AlertDialogDescription> - 저장되지 않은 변경사항이 있습니다. 그래도 나가시겠습니까? - </AlertDialogDescription> - </AlertDialogHeader> - <AlertDialogFooter> - <AlertDialogCancel>취소</AlertDialogCancel> - <AlertDialogAction onClick={() => { - setIsExitDialogOpen(false); - onOpenChange(false); - }}> - 저장하지 않고 나가기 - </AlertDialogAction> - </AlertDialogFooter> - </AlertDialogContent> - </AlertDialog> - )} - </> - ); -}
\ No newline at end of file diff --git a/lib/rfqs-tech/table/add-rfq-dialog.tsx b/lib/rfqs-tech/table/add-rfq-dialog.tsx deleted file mode 100644 index acd3c34e..00000000 --- a/lib/rfqs-tech/table/add-rfq-dialog.tsx +++ /dev/null @@ -1,295 +0,0 @@ -"use client" - -import * as React from "react" -import { useForm } from "react-hook-form" -import { zodResolver } from "@hookform/resolvers/zod" -import { toast } from "sonner" - -import { Dialog, DialogTrigger, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter } from "@/components/ui/dialog" -import { Button } from "@/components/ui/button" -import { Input } from "@/components/ui/input" -import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from "@/components/ui/form" - -import { useSession } from "next-auth/react" -import { createRfqSchema, type CreateRfqSchema } from "../validations" -import { createRfq, generateNextRfqCode } from "../service" -import { type Project } from "../service" -import { EstimateProjectSelector } from "@/components/BidProjectSelector" - - - - -export function AddRfqDialog() { - const [open, setOpen] = React.useState(false) - const { data: session, status } = useSession() - const [isLoadingRfqCode, setIsLoadingRfqCode] = React.useState(false) - - - // Get the user ID safely, ensuring it's a valid number - const userId = React.useMemo(() => { - const id = session?.user?.id ? Number(session.user.id) : null; - - return id; - }, [session, status]); - - - - // RHF + Zod - const form = useForm<CreateRfqSchema>({ - resolver: zodResolver(createRfqSchema), - defaultValues: { - rfqCode: "", - description: "", - projectId: undefined, - dueDate: new Date(), - status: "DRAFT", - // Don't set createdBy yet - we'll set it when the form is submitted - createdBy: undefined, - }, - }); - - // Update form values when session loads - React.useEffect(() => { - if (status === "authenticated" && userId) { - form.setValue("createdBy", userId); - } - }, [status, userId, form]); - - // 다이얼로그가 열릴 때 자동으로 RFQ 코드 생성 - React.useEffect(() => { - if (open) { - const generateRfqCode = async () => { - setIsLoadingRfqCode(true); - try { - // 서버 액션 호출 - const result = await generateNextRfqCode(); - - if (result.error) { - toast.error(`RFQ 코드 생성 실패: ${result.error}`); - return; - } - - // 생성된 코드를 폼에 설정 - form.setValue("rfqCode", result.code); - } catch (error) { - console.error("RFQ 코드 생성 오류:", error); - toast.error("RFQ 코드 생성에 실패했습니다"); - } finally { - setIsLoadingRfqCode(false); - } - }; - - generateRfqCode(); - } - }, [open, form]); - - - - - - const handleBidProjectSelect = (project: Project | null) => { - if (project === null) { - return; - } - - form.setValue("bidProjectId", project.id); - }; - - - async function onSubmit(data: CreateRfqSchema) { - // Check if user is authenticated before submitting - if (status !== "authenticated" || !userId) { - toast.error("사용자 인증이 필요합니다. 다시 로그인해주세요."); - return; - } - - // Make sure createdBy is set with the current user ID - const submitData = { - ...data, - createdBy: userId - }; - - const result = await createRfq(submitData); - if (result.error) { - toast.error(`에러: ${result.error}`); - return; - } - - toast.success("RFQ가 성공적으로 생성되었습니다."); - form.reset(); - - setOpen(false); - } - - function handleDialogOpenChange(nextOpen: boolean) { - if (!nextOpen) { - form.reset(); - } - setOpen(nextOpen); - } - - // Return a message or disabled state if user is not authenticated - if (status === "loading") { - return <Button variant="outline" size="sm" disabled>Loading...</Button>; - } - - - return ( - <Dialog open={open} onOpenChange={handleDialogOpenChange}> - {/* 모달을 열기 위한 버튼 */} - <DialogTrigger asChild> - <Button variant="default" size="sm"> - Add RFQ - </Button> - </DialogTrigger> - - <DialogContent> - <DialogHeader> - <DialogTitle>Create New RFQ</DialogTitle> - <DialogDescription> - 새 RFQ 정보를 입력하고 <b>Create</b> 버튼을 누르세요. - </DialogDescription> - </DialogHeader> - - <Form {...form}> - <form onSubmit={form.handleSubmit(onSubmit)}> - <div className="space-y-4 py-4"> - - {/* Project Selector */} - <FormField - control={form.control} - name="projectId" - render={({ field }) => ( - <FormItem> - <FormLabel>Project</FormLabel> - <FormControl> - <EstimateProjectSelector - selectedProjectId={field.value} - onProjectSelect={handleBidProjectSelect} - placeholder="견적 프로젝트 선택..." - /> - </FormControl> - <FormMessage /> - </FormItem> - )} - /> - - {/* rfqCode - 자동 생성되고 읽기 전용 */} - <FormField - control={form.control} - name="rfqCode" - render={({ field }) => ( - <FormItem> - <FormLabel>RFQ Code</FormLabel> - <FormControl> - <div className="flex"> - <Input - placeholder="자동으로 생성 중..." - {...field} - disabled={true} - className="bg-muted" - /> - {isLoadingRfqCode && ( - <div className="ml-2 flex items-center"> - <div className="h-4 w-4 animate-spin rounded-full border-2 border-primary border-t-transparent"></div> - </div> - )} - </div> - </FormControl> - <div className="text-xs text-muted-foreground mt-1"> - RFQ 타입과 현재 날짜를 기준으로 자동 생성됩니다 - </div> - <FormMessage /> - </FormItem> - )} - /> - - {/* description */} - <FormField - control={form.control} - name="description" - render={({ field }) => ( - <FormItem> - <FormLabel>RFQ Description</FormLabel> - <FormControl> - <Input placeholder="e.g. 설명을 입력하세요" {...field} /> - </FormControl> - <FormMessage /> - </FormItem> - )} - /> - - {/* dueDate */} - <FormField - control={form.control} - name="dueDate" - render={({ field }) => ( - <FormItem> - <FormLabel>Due Date</FormLabel> - <FormControl> - <Input - type="date" - value={field.value ? field.value.toISOString().slice(0, 10) : ""} - onChange={(e) => { - const val = e.target.value - if (val) { - const date = new Date(val); - // 날짜 1일씩 밀리는 문제로 우선 KTC로 입력 - // 추후 아래와 같이 수정 - // 1. 해당 유저 타임존 값으로 입력 - // 2. DB에는 UTC 타임존 값으로 저장 - // 3. 출력시 유저별 타임존 값으로 변환해 출력 - // 4. 어떤 타임존으로 나오는지도 함께 렌더링 - // field.onChange(new Date(val + "T00:00:00")) - field.onChange(date); - } - }} - /> - </FormControl> - <FormMessage /> - </FormItem> - )} - /> - - {/* status (Read-only) */} - <FormField - control={form.control} - name="status" - render={({ field }) => ( - <FormItem> - <FormLabel>Status</FormLabel> - <FormControl> - <Input - disabled - className="capitalize" - {...field} - onChange={() => { }} // Prevent changes - /> - </FormControl> - <FormMessage /> - </FormItem> - )} - /> - </div> - - <DialogFooter> - <Button - type="button" - variant="outline" - onClick={() => setOpen(false)} - > - Cancel - </Button> - <Button - type="submit" - disabled={form.formState.isSubmitting || status !== "authenticated"} - > - Create - </Button> - </DialogFooter> - </form> - </Form> - </DialogContent> - </Dialog> - ) -}
\ No newline at end of file diff --git a/lib/rfqs-tech/table/attachment-rfq-sheet.tsx b/lib/rfqs-tech/table/attachment-rfq-sheet.tsx deleted file mode 100644 index d06fae09..00000000 --- a/lib/rfqs-tech/table/attachment-rfq-sheet.tsx +++ /dev/null @@ -1,426 +0,0 @@ -"use client" - -import * as React from "react" -import { z } from "zod" -import { useForm, useFieldArray } from "react-hook-form" -import { zodResolver } from "@hookform/resolvers/zod" - -import { - Sheet, - SheetContent, - SheetHeader, - SheetTitle, - SheetDescription, - SheetFooter, - SheetClose, -} from "@/components/ui/sheet" -import { Button } from "@/components/ui/button" -import { - Form, - FormControl, - FormField, - FormItem, - FormLabel, - FormMessage, - FormDescription -} from "@/components/ui/form" -import { Loader, Download, X, Eye, AlertCircle } from "lucide-react" -import { useToast } from "@/hooks/use-toast" -import { Badge } from "@/components/ui/badge" - -import { - Dropzone, - DropzoneDescription, - DropzoneInput, - DropzoneTitle, - DropzoneUploadIcon, - DropzoneZone, -} from "@/components/ui/dropzone" -import { - FileList, - FileListAction, - FileListDescription, - FileListHeader, - FileListIcon, - FileListInfo, - FileListItem, - FileListName, - FileListSize, -} from "@/components/ui/file-list" - -import prettyBytes from "pretty-bytes" -import { processRfqAttachments } from "../service" -import { format } from "path" -import { formatDate } from "@/lib/utils" -import { RfqWithItemCount } from "@/db/schema/rfq" - -const MAX_FILE_SIZE = 6e8 // 600MB - -/** 기존 첨부 파일 정보 */ -interface ExistingAttachment { - id: number - fileName: string - filePath: string - createdAt?: Date // or Date - vendorId?: number | null - size?: number -} - -/** 새로 업로드할 파일 */ -const newUploadSchema = z.object({ - fileObj: z.any().optional(), // 실제 File -}) - -/** 기존 첨부 (react-hook-form에서 관리) */ -const existingAttachSchema = z.object({ - id: z.number(), - fileName: z.string(), - filePath: z.string(), - vendorId: z.number().nullable().optional(), - createdAt: z.custom<Date>().optional(), // or use z.any().optional() - size: z.number().optional(), -}) - -/** RHF 폼 전체 스키마 */ -const attachmentsFormSchema = z.object({ - rfqId: z.number().int(), - existing: z.array(existingAttachSchema), - newUploads: z.array(newUploadSchema), -}) - -type AttachmentsFormValues = z.infer<typeof attachmentsFormSchema> - -interface RfqAttachmentsSheetProps - extends React.ComponentPropsWithRef<typeof Sheet> { - defaultAttachments?: ExistingAttachment[] - rfq: RfqWithItemCount | null - /** 업로드/삭제 후 상위 테이블에 itemCount 등을 업데이트하기 위한 콜백 */ - onAttachmentsUpdated?: (rfqId: number, newItemCount: number) => void -} - -/** - * RfqAttachmentsSheet: - * - 기존 첨부 목록 (다운로드 + 삭제) - * - 새 파일 Dropzone - * - Save 시 processRfqAttachments(server action) - */ -export function RfqAttachmentsSheet({ - defaultAttachments = [], - onAttachmentsUpdated, - rfq, - ...props -}: RfqAttachmentsSheetProps) { - const { toast } = useToast() - const [isPending, startUpdate] = React.useTransition() - const rfqId = rfq?.rfqId ?? 0; - - // 편집 가능 여부 확인 - DRAFT 상태일 때만 편집 가능 - const isEditable = rfq?.status === "DRAFT"; - - // React Hook Form - const form = useForm<AttachmentsFormValues>({ - resolver: zodResolver(attachmentsFormSchema), - defaultValues: { - rfqId, - existing: [], - newUploads: [], - }, - }) - - const { reset, control, handleSubmit } = form - - // defaultAttachments가 바뀔 때마다, RHF 상태를 reset - React.useEffect(() => { - reset({ - rfqId, - existing: defaultAttachments.map((att) => ({ - ...att, - vendorId: att.vendorId ?? null, - size: att.size ?? undefined, - })), - newUploads: [], - }) - }, [rfqId, defaultAttachments, reset]) - - // Field Arrays - const { - fields: existingFields, - remove: removeExisting, - } = useFieldArray({ control, name: "existing" }) - - const { - fields: newUploadFields, - append: appendNewUpload, - remove: removeNewUpload, - } = useFieldArray({ control, name: "newUploads" }) - - // 기존 첨부 항목 중 삭제된 것 찾기 - function findRemovedExistingIds(data: AttachmentsFormValues): number[] { - const finalIds = data.existing.map((att) => att.id) - const originalIds = defaultAttachments.map((att) => att.id) - return originalIds.filter((id) => !finalIds.includes(id)) - } - - async function onSubmit(data: AttachmentsFormValues) { - // 편집 불가능한 상태에서는 제출 방지 - if (!isEditable) return; - - startUpdate(async () => { - try { - const removedExistingIds = findRemovedExistingIds(data) - const newFiles = data.newUploads - .map((it) => it.fileObj) - .filter((f): f is File => !!f) - - // 서버 액션 - const res = await processRfqAttachments({ - rfqId, - removedExistingIds, - newFiles, - vendorId: null, // vendor ID if needed - }) - - if (!res.ok) throw new Error(res.error ?? "Unknown error") - - const newCount = res.updatedItemCount ?? 0 - - toast({ - variant: "default", - title: "Success", - description: "File(s) updated", - }) - - // 상위 테이블 등에 itemCount 업데이트 - onAttachmentsUpdated?.(rfqId, newCount) - - // 모달 닫기 - props.onOpenChange?.(false) - } catch (err) { - toast({ - variant: "destructive", - title: "Error", - description: String(err), - }) - } - }) - } - - /** 기존 첨부 - X 버튼 */ - function handleRemoveExisting(idx: number) { - // 편집 불가능한 상태에서는 삭제 방지 - if (!isEditable) return; - removeExisting(idx) - } - - /** 드롭존에서 파일 받기 */ - function handleDropAccepted(acceptedFiles: File[]) { - // 편집 불가능한 상태에서는 파일 추가 방지 - if (!isEditable) return; - const mapped = acceptedFiles.map((file) => ({ fileObj: file })) - appendNewUpload(mapped) - } - - /** 드롭존에서 파일 거부(에러) */ - function handleDropRejected(fileRejections: any[]) { - // 편집 불가능한 상태에서는 무시 - if (!isEditable) return; - - fileRejections.forEach((rej) => { - toast({ - variant: "destructive", - title: "File Error", - description: rej.file.name + " not accepted", - }) - }) - } - - return ( - <Sheet {...props}> - <SheetContent className="flex flex-col gap-6 sm:max-w-sm"> - <SheetHeader> - <SheetTitle className="flex items-center gap-2"> - {isEditable ? "Manage Attachments" : "View Attachments"} - {rfq?.status && ( - <Badge - variant={rfq.status === "DRAFT" ? "outline" : "secondary"} - className="ml-1" - > - {rfq.status} - </Badge> - )} - </SheetTitle> - <SheetDescription> - {`RFQ ${rfq?.rfqCode} - `} - {isEditable ? '파일 첨부/삭제' : '첨부 파일 보기'} - {!isEditable && ( - <div className="mt-1 text-xs flex items-center gap-1 text-amber-600"> - <AlertCircle className="h-3 w-3" /> - <span>드래프트 상태가 아닌 RFQ는 첨부파일을 수정할 수 없습니다.</span> - </div> - )} - </SheetDescription> - </SheetHeader> - - <Form {...form}> - <form onSubmit={handleSubmit(onSubmit)} className="flex flex-col gap-4"> - {/* 1) 기존 첨부 목록 */} - <div className="space-y-2"> - <p className="font-semibold text-sm">Existing Attachments</p> - {existingFields.length === 0 && ( - <p className="text-sm text-muted-foreground">No existing attachments</p> - )} - {existingFields.map((field, index) => { - const vendorLabel = field.vendorId ? "(Vendor)" : "(Internal)" - return ( - <div - key={field.id} - className="flex items-center justify-between rounded border p-2" - > - <div className="flex flex-col text-sm"> - <span className="font-medium"> - {field.fileName} {vendorLabel} - </span> - {field.size && ( - <span className="text-xs text-muted-foreground"> - {Math.round(field.size / 1024)} KB - </span> - )} - {field.createdAt && ( - <span className="text-xs text-muted-foreground"> - Created at {formatDate(field.createdAt)} - </span> - )} - </div> - <div className="flex items-center gap-2"> - {/* 1) Download button (if filePath) */} - {field.filePath && ( - <a - href={`/api/rfq-download?path=${encodeURIComponent(field.filePath)}`} - download={field.fileName} - className="text-sm" - > - <Button variant="ghost" size="icon" type="button"> - <Download className="h-4 w-4" /> - </Button> - </a> - )} - {/* 2) Remove button - 편집 가능할 때만 표시 */} - {isEditable && ( - <Button - type="button" - variant="ghost" - size="icon" - onClick={() => handleRemoveExisting(index)} - > - <X className="h-4 w-4" /> - </Button> - )} - </div> - </div> - ) - })} - </div> - - {/* 2) Dropzone for new uploads - 편집 가능할 때만 표시 */} - {isEditable ? ( - <> - <Dropzone - maxSize={MAX_FILE_SIZE} - onDropAccepted={handleDropAccepted} - onDropRejected={handleDropRejected} - > - {({ maxSize }) => ( - <FormField - control={control} - name="newUploads" // not actually used for storing each file detail - render={() => ( - <FormItem> - <FormLabel>Drop Files Here</FormLabel> - <DropzoneZone className="flex justify-center"> - <FormControl> - <DropzoneInput /> - </FormControl> - <div className="flex items-center gap-6"> - <DropzoneUploadIcon /> - <div className="grid gap-0.5"> - <DropzoneTitle>Drop to upload</DropzoneTitle> - <DropzoneDescription> - Max size: {maxSize ? prettyBytes(maxSize) : "??? MB"} - </DropzoneDescription> - </div> - </div> - </DropzoneZone> - <FormDescription>Alternatively, click browse.</FormDescription> - <FormMessage /> - </FormItem> - )} - /> - )} - </Dropzone> - - {/* newUpload fields -> FileList */} - {newUploadFields.length > 0 && ( - <div className="grid gap-4"> - <h6 className="font-semibold leading-none tracking-tight"> - {`Files (${newUploadFields.length})`} - </h6> - <FileList> - {newUploadFields.map((field, idx) => { - const fileObj = form.getValues(`newUploads.${idx}.fileObj`) - if (!fileObj) return null - - const fileName = fileObj.name - const fileSize = fileObj.size - return ( - <FileListItem key={field.id}> - <FileListHeader> - <FileListIcon /> - <FileListInfo> - <FileListName>{fileName}</FileListName> - <FileListDescription> - {`${prettyBytes(fileSize)}`} - </FileListDescription> - </FileListInfo> - <FileListAction onClick={() => removeNewUpload(idx)}> - <X /> - <span className="sr-only">Remove</span> - </FileListAction> - </FileListHeader> - </FileListItem> - ) - })} - </FileList> - </div> - )} - </> - ) : ( - <div className="p-3 bg-muted rounded-md flex items-center justify-center"> - <div className="text-center text-sm text-muted-foreground"> - <Eye className="h-4 w-4 mx-auto mb-2" /> - <p>보기 모드에서는 파일 첨부를 할 수 없습니다.</p> - </div> - </div> - )} - - <SheetFooter className="gap-2 pt-2 sm:space-x-0"> - <SheetClose asChild> - <Button type="button" variant="outline"> - {isEditable ? "Cancel" : "Close"} - </Button> - </SheetClose> - {isEditable && ( - <Button - type="submit" - disabled={isPending || (form.getValues().newUploads.length === 0 && defaultAttachments.length === form.getValues().existing.length)} - > - {isPending && <Loader className="mr-2 h-4 w-4 animate-spin" />} - Save - </Button> - )} - </SheetFooter> - </form> - </Form> - </SheetContent> - </Sheet> - ) -}
\ No newline at end of file diff --git a/lib/rfqs-tech/table/delete-rfqs-dialog.tsx b/lib/rfqs-tech/table/delete-rfqs-dialog.tsx deleted file mode 100644 index 729bc526..00000000 --- a/lib/rfqs-tech/table/delete-rfqs-dialog.tsx +++ /dev/null @@ -1,149 +0,0 @@ -"use client" - -import * as React from "react" -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 { RfqWithItemCount } from "@/db/schema/rfq" -import { removeRfqs } from "../service" - -interface DeleteRfqsDialogProps - extends React.ComponentPropsWithoutRef<typeof Dialog> { - rfqs: Row<RfqWithItemCount>["original"][] - showTrigger?: boolean - onSuccess?: () => void -} - -export function DeleteRfqsDialog({ - rfqs, - showTrigger = true, - onSuccess, - ...props -}: DeleteRfqsDialogProps) { - const [isDeletePending, startDeleteTransition] = React.useTransition() - const isDesktop = useMediaQuery("(min-width: 640px)") - - function onDelete() { - startDeleteTransition(async () => { - const { error } = await removeRfqs({ - ids: rfqs.map((rfq) => rfq.rfqId), - }) - - if (error) { - toast.error(error) - return - } - - props.onOpenChange?.(false) - toast.success("Tasks deleted") - onSuccess?.() - }) - } - - if (isDesktop) { - return ( - <Dialog {...props}> - {showTrigger ? ( - <DialogTrigger asChild> - <Button variant="outline" size="sm"> - <Trash className="mr-2 size-4" aria-hidden="true" /> - Delete ({rfqs.length}) - </Button> - </DialogTrigger> - ) : null} - <DialogContent> - <DialogHeader> - <DialogTitle>Are you absolutely sure?</DialogTitle> - <DialogDescription> - This action cannot be undone. This will permanently delete your{" "} - <span className="font-medium">{rfqs.length}</span> - {rfqs.length === 1 ? " task" : " rfqs"} from our servers. - </DialogDescription> - </DialogHeader> - <DialogFooter className="gap-2 sm:space-x-0"> - <DialogClose asChild> - <Button variant="outline">Cancel</Button> - </DialogClose> - <Button - aria-label="Delete selected rows" - variant="destructive" - onClick={onDelete} - disabled={isDeletePending} - > - {isDeletePending && ( - <Loader - className="mr-2 size-4 animate-spin" - aria-hidden="true" - /> - )} - Delete - </Button> - </DialogFooter> - </DialogContent> - </Dialog> - ) - } - - return ( - <Drawer {...props}> - {showTrigger ? ( - <DrawerTrigger asChild> - <Button variant="outline" size="sm"> - <Trash className="mr-2 size-4" aria-hidden="true" /> - Delete ({rfqs.length}) - </Button> - </DrawerTrigger> - ) : null} - <DrawerContent> - <DrawerHeader> - <DrawerTitle>Are you absolutely sure?</DrawerTitle> - <DrawerDescription> - This action cannot be undone. This will permanently delete your{" "} - <span className="font-medium">{rfqs.length}</span> - {rfqs.length === 1 ? " task" : " rfqs"} from our servers. - </DrawerDescription> - </DrawerHeader> - <DrawerFooter className="gap-2 sm:space-x-0"> - <DrawerClose asChild> - <Button variant="outline">Cancel</Button> - </DrawerClose> - <Button - aria-label="Delete selected rows" - variant="destructive" - onClick={onDelete} - disabled={isDeletePending} - > - {isDeletePending && ( - <Loader className="mr-2 size-4 animate-spin" aria-hidden="true" /> - )} - Delete - </Button> - </DrawerFooter> - </DrawerContent> - </Drawer> - ) -} diff --git a/lib/rfqs-tech/table/feature-flags-provider.tsx b/lib/rfqs-tech/table/feature-flags-provider.tsx deleted file mode 100644 index 81131894..00000000 --- a/lib/rfqs-tech/table/feature-flags-provider.tsx +++ /dev/null @@ -1,108 +0,0 @@ -"use client" - -import * as React from "react" -import { useQueryState } from "nuqs" - -import { dataTableConfig, type DataTableConfig } from "@/config/data-table" -import { cn } from "@/lib/utils" -import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group" -import { - Tooltip, - TooltipContent, - TooltipTrigger, -} from "@/components/ui/tooltip" - -type FeatureFlagValue = DataTableConfig["featureFlags"][number]["value"] - -interface FeatureFlagsContextProps { - featureFlags: FeatureFlagValue[] - setFeatureFlags: (value: FeatureFlagValue[]) => void -} - -const FeatureFlagsContext = React.createContext<FeatureFlagsContextProps>({ - featureFlags: [], - setFeatureFlags: () => {}, -}) - -export function useFeatureFlags() { - const context = React.useContext(FeatureFlagsContext) - if (!context) { - throw new Error( - "useFeatureFlags must be used within a FeatureFlagsProvider" - ) - } - return context -} - -interface FeatureFlagsProviderProps { - children: React.ReactNode -} - -export function FeatureFlagsProvider({ children }: FeatureFlagsProviderProps) { - const [featureFlags, setFeatureFlags] = useQueryState<FeatureFlagValue[]>( - "flags", - { - defaultValue: [], - parse: (value) => value.split(",") as FeatureFlagValue[], - serialize: (value) => value.join(","), - eq: (a, b) => - a.length === b.length && a.every((value, index) => value === b[index]), - clearOnDefault: true, - shallow: false, - } - ) - - return ( - <FeatureFlagsContext.Provider - value={{ - featureFlags, - setFeatureFlags: (value) => void setFeatureFlags(value), - }} - > - <div className="w-full overflow-x-auto"> - <ToggleGroup - type="multiple" - variant="outline" - size="sm" - value={featureFlags} - onValueChange={(value: FeatureFlagValue[]) => setFeatureFlags(value)} - className="w-fit gap-0" - > - {dataTableConfig.featureFlags.map((flag, index) => ( - <Tooltip key={flag.value}> - <ToggleGroupItem - value={flag.value} - className={cn( - "gap-2 whitespace-nowrap rounded-none px-3 text-xs data-[state=on]:bg-accent/70 data-[state=on]:hover:bg-accent/90", - { - "rounded-l-sm border-r-0": index === 0, - "rounded-r-sm": - index === dataTableConfig.featureFlags.length - 1, - } - )} - asChild - > - <TooltipTrigger> - <flag.icon className="size-3.5 shrink-0" aria-hidden="true" /> - {flag.label} - </TooltipTrigger> - </ToggleGroupItem> - <TooltipContent - align="start" - side="bottom" - sideOffset={6} - className="flex max-w-60 flex-col space-y-1.5 border bg-background py-2 font-semibold text-foreground" - > - <div>{flag.tooltipTitle}</div> - <div className="text-xs text-muted-foreground"> - {flag.tooltipDescription} - </div> - </TooltipContent> - </Tooltip> - ))} - </ToggleGroup> - </div> - {children} - </FeatureFlagsContext.Provider> - ) -} diff --git a/lib/rfqs-tech/table/feature-flags.tsx b/lib/rfqs-tech/table/feature-flags.tsx deleted file mode 100644 index aaae6af2..00000000 --- a/lib/rfqs-tech/table/feature-flags.tsx +++ /dev/null @@ -1,96 +0,0 @@ -"use client" - -import * as React from "react" -import { useQueryState } from "nuqs" - -import { dataTableConfig, type DataTableConfig } from "@/config/data-table" -import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group" -import { - Tooltip, - TooltipContent, - TooltipTrigger, -} from "@/components/ui/tooltip" - -type FeatureFlagValue = DataTableConfig["featureFlags"][number]["value"] - -interface TasksTableContextProps { - featureFlags: FeatureFlagValue[] - setFeatureFlags: (value: FeatureFlagValue[]) => void -} - -const TasksTableContext = React.createContext<TasksTableContextProps>({ - featureFlags: [], - setFeatureFlags: () => {}, -}) - -export function useTasksTable() { - const context = React.useContext(TasksTableContext) - if (!context) { - throw new Error("useTasksTable must be used within a TasksTableProvider") - } - return context -} - -export function TasksTableProvider({ children }: React.PropsWithChildren) { - const [featureFlags, setFeatureFlags] = useQueryState<FeatureFlagValue[]>( - "featureFlags", - { - defaultValue: [], - parse: (value) => value.split(",") as FeatureFlagValue[], - serialize: (value) => value.join(","), - eq: (a, b) => - a.length === b.length && a.every((value, index) => value === b[index]), - clearOnDefault: true, - } - ) - - return ( - <TasksTableContext.Provider - value={{ - featureFlags, - setFeatureFlags: (value) => void setFeatureFlags(value), - }} - > - <div className="w-full overflow-x-auto"> - <ToggleGroup - type="multiple" - variant="outline" - size="sm" - value={featureFlags} - onValueChange={(value: FeatureFlagValue[]) => setFeatureFlags(value)} - className="w-fit" - > - {dataTableConfig.featureFlags.map((flag) => ( - <Tooltip key={flag.value}> - <ToggleGroupItem - value={flag.value} - className="whitespace-nowrap px-3 text-xs" - asChild - > - <TooltipTrigger> - <flag.icon - className="mr-2 size-3.5 shrink-0" - aria-hidden="true" - /> - {flag.label} - </TooltipTrigger> - </ToggleGroupItem> - <TooltipContent - align="start" - side="bottom" - sideOffset={6} - className="flex max-w-60 flex-col space-y-1.5 border bg-background py-2 font-semibold text-foreground" - > - <div>{flag.tooltipTitle}</div> - <div className="text-xs text-muted-foreground"> - {flag.tooltipDescription} - </div> - </TooltipContent> - </Tooltip> - ))} - </ToggleGroup> - </div> - {children} - </TasksTableContext.Provider> - ) -} diff --git a/lib/rfqs-tech/table/rfqs-table-columns.tsx b/lib/rfqs-tech/table/rfqs-table-columns.tsx deleted file mode 100644 index 03089341..00000000 --- a/lib/rfqs-tech/table/rfqs-table-columns.tsx +++ /dev/null @@ -1,308 +0,0 @@ -"use client" - -import * as React from "react" -import { type DataTableRowAction } from "@/types/table" -import { type ColumnDef } from "@tanstack/react-table" -import { Ellipsis, Paperclip, Package } from "lucide-react" -import { toast } from "sonner" - -import { formatDate } from "@/lib/utils" -import { Badge } from "@/components/ui/badge" -import { Button } from "@/components/ui/button" -import { Checkbox } from "@/components/ui/checkbox" -import { - DropdownMenu, - DropdownMenuContent, - DropdownMenuItem, - DropdownMenuRadioGroup, - DropdownMenuRadioItem, - DropdownMenuSeparator, - DropdownMenuShortcut, - DropdownMenuSub, - DropdownMenuSubContent, - DropdownMenuSubTrigger, - DropdownMenuTrigger, -} from "@/components/ui/dropdown-menu" - -import { getRFQStatusIcon } from "@/lib/tasks/utils" -import { rfqsColumnsConfig } from "@/config/rfqsColumnsConfig" -import { RfqWithItemCount } from "@/db/schema/rfq" -import { DataTableColumnHeaderSimple } from "@/components/data-table/data-table-column-simple-header" -import { useRouter } from "next/navigation" - -type NextRouter = ReturnType<typeof useRouter>; - -interface GetColumnsProps { - setRowAction: React.Dispatch< - React.SetStateAction<DataTableRowAction<RfqWithItemCount> | null> - > - openItemsModal: (rfqId: number) => void - openAttachmentsSheet: (rfqId: number) => void - router: NextRouter -} - -/** - * tanstack table 컬럼 정의 (중첩 헤더 버전) - */ -export function getColumns({ - setRowAction, - openItemsModal, - openAttachmentsSheet, - router, -}: GetColumnsProps): ColumnDef<RfqWithItemCount>[] { - // ---------------------------------------------------------------- - // 1) select 컬럼 (체크박스) - // ---------------------------------------------------------------- - const selectColumn: ColumnDef<RfqWithItemCount> = { - id: "select", - header: ({ table }) => ( - <Checkbox - checked={ - table.getIsAllPageRowsSelected() || - (table.getIsSomePageRowsSelected() && "indeterminate") - } - onCheckedChange={(value) => table.toggleAllPageRowsSelected(!!value)} - aria-label="Select all" - className="translate-y-0.5" - /> - ), - cell: ({ row }) => ( - <Checkbox - checked={row.getIsSelected()} - onCheckedChange={(value) => row.toggleSelected(!!value)} - aria-label="Select row" - className="translate-y-0.5" - /> - ), - size: 40, - enableSorting: false, - enableHiding: false, - } - - // ---------------------------------------------------------------- - // 2) actions 컬럼 (Dropdown 메뉴) - // ---------------------------------------------------------------- - const actionsColumn: ColumnDef<RfqWithItemCount> = { - id: "actions", - enableHiding: false, - cell: function Cell({ row }) { - // Proceed 버튼 클릭 시 호출되는 함수 - const handleProceed = () => { - const rfq = row.original - const itemCount = Number(rfq.itemCount || 0) - const attachCount = Number(rfq.attachCount || 0) - - // 아이템과 첨부파일이 모두 0보다 커야 진행 가능 - if (itemCount > 0 && attachCount > 0) { - router.push( - `/evcp/rfq-tech/${rfq.rfqId}` - ) - } else { - // 조건을 충족하지 않는 경우 토스트 알림 표시 - if (itemCount === 0 && attachCount === 0) { - toast.error("아이템과 첨부파일을 먼저 추가해주세요.") - } else if (itemCount === 0) { - toast.error("아이템을 먼저 추가해주세요.") - } else { - toast.error("첨부파일을 먼저 추가해주세요.") - } - } - } - - return ( - <DropdownMenu> - <DropdownMenuTrigger asChild> - <Button - aria-label="Open menu" - variant="ghost" - className="flex size-8 p-0 data-[state=open]:bg-muted" - > - <Ellipsis className="size-4" aria-hidden="true" /> - </Button> - </DropdownMenuTrigger> - <DropdownMenuContent align="end" className="w-40"> - <DropdownMenuItem - onSelect={() => setRowAction({ row, type: "update" })} - > - Edit - </DropdownMenuItem> - <DropdownMenuSeparator /> - <DropdownMenuItem onSelect={handleProceed}> - {row.original.status ==="DRAFT"?"Proceed":"View Detail"} - <DropdownMenuShortcut>↵</DropdownMenuShortcut> - </DropdownMenuItem> - <DropdownMenuSeparator /> - <DropdownMenuItem - onSelect={() => setRowAction({ row, type: "delete" })} - > - Delete - <DropdownMenuShortcut>⌘⌫</DropdownMenuShortcut> - </DropdownMenuItem> - </DropdownMenuContent> - </DropdownMenu> - ) - }, - size: 40, - } - - // ---------------------------------------------------------------- - // 3) itemsColumn (아이템 개수 표시: 아이콘 + Badge) - // ---------------------------------------------------------------- - const itemsColumn: ColumnDef<RfqWithItemCount> = { - id: "items", - header: "Items", - cell: ({ row }) => { - const rfq = row.original - const itemCount = rfq.itemCount || 0 - - const handleClick = () => { - openItemsModal(rfq.rfqId) - } - - return ( - <Button - variant="ghost" - size="sm" - className="relative h-8 w-8 p-0 group" - onClick={handleClick} - aria-label={ - itemCount > 0 ? `View ${itemCount} items` : "Add items" - } - > - <Package className="h-4 w-4 text-muted-foreground group-hover:text-primary transition-colors" /> - {itemCount > 0 && ( - <Badge - variant="secondary" - className="pointer-events-none absolute -top-1 -right-1 h-4 min-w-[1rem] p-0 text-[0.625rem] leading-none flex items-center justify-center" - > - {itemCount} - </Badge> - )} - <span className="sr-only"> - {itemCount > 0 ? `${itemCount} Items` : "Add Items"} - </span> - </Button> - ) - }, - enableSorting: false, - size: 60, - } - - // ---------------------------------------------------------------- - // 4) attachmentsColumn (첨부파일 개수 표시: 아이콘 + Badge) - // ---------------------------------------------------------------- - const attachmentsColumn: ColumnDef<RfqWithItemCount> = { - id: "attachments", - header: "Attachments", - cell: ({ row }) => { - const fileCount = row.original.attachCount ?? 0 - - const handleClick = () => { - openAttachmentsSheet(row.original.rfqId) - } - - return ( - <Button - variant="ghost" - size="sm" - className="relative h-8 w-8 p-0 group" - onClick={handleClick} - aria-label={ - fileCount > 0 ? `View ${fileCount} files` : "Add files" - } - > - <Paperclip className="h-4 w-4 text-muted-foreground group-hover:text-primary transition-colors" /> - {fileCount > 0 && ( - <Badge - variant="secondary" - className="pointer-events-none absolute -top-1 -right-1 h-4 min-w-[1rem] p-0 text-[0.625rem] leading-none flex items-center justify-center" - > - {fileCount} - </Badge> - )} - <span className="sr-only"> - {fileCount > 0 ? `${fileCount} Files` : "Add Files"} - </span> - </Button> - ) - }, - enableSorting: false, - size: 60, - } - - // ---------------------------------------------------------------- - // 5) 일반 컬럼들을 "그룹"별로 묶어 중첩 columns 생성 - // ---------------------------------------------------------------- - const groupMap: Record<string, ColumnDef<RfqWithItemCount>[]> = {} - - rfqsColumnsConfig.forEach((cfg) => { - const groupName = cfg.group || "_noGroup" - - if (!groupMap[groupName]) { - groupMap[groupName] = [] - } - - // child column 정의 - const childCol: ColumnDef<RfqWithItemCount> = { - accessorKey: cfg.id, - enableResizing: true, - header: ({ column }) => ( - <DataTableColumnHeaderSimple column={column} title={cfg.label} /> - ), - meta: { - excelHeader: cfg.excelHeader, - group: cfg.group, - type: cfg.type, - }, - cell: ({ row, cell }) => { - if (cfg.id === "status") { - const statusVal = row.original.status - if (!statusVal) return null - const Icon = getRFQStatusIcon( - statusVal as "DRAFT" | "PUBLISHED" | "EVALUATION" | "AWARDED" - ) - return ( - <div className="flex w-[6.25rem] items-center"> - <Icon className="mr-2 size-4 text-muted-foreground" aria-hidden="true" /> - <span className="capitalize">{statusVal}</span> - </div> - ) - } - - if (cfg.id === "createdAt" || cfg.id === "updatedAt") { - const dateVal = cell.getValue() as Date - return formatDate(dateVal) - } - - return row.getValue(cfg.id) ?? "" - }, - } - - groupMap[groupName].push(childCol) - }) - - // groupMap -> nestedColumns - const nestedColumns: ColumnDef<RfqWithItemCount>[] = [] - Object.entries(groupMap).forEach(([groupName, colDefs]) => { - if (groupName === "_noGroup") { - nestedColumns.push(...colDefs) - } else { - nestedColumns.push({ - id: groupName, - header: groupName, - columns: colDefs, - }) - } - }) - - // ---------------------------------------------------------------- - // 6) 최종 컬럼 배열 - // ---------------------------------------------------------------- - return [ - selectColumn, - ...nestedColumns, - attachmentsColumn, // 첨부파일 - actionsColumn, - itemsColumn, // 아이템 - ] -}
\ No newline at end of file diff --git a/lib/rfqs-tech/table/rfqs-table-floating-bar.tsx b/lib/rfqs-tech/table/rfqs-table-floating-bar.tsx deleted file mode 100644 index daef7e0b..00000000 --- a/lib/rfqs-tech/table/rfqs-table-floating-bar.tsx +++ /dev/null @@ -1,338 +0,0 @@ -"use client" - -import * as React from "react" -import { Table } from "@tanstack/react-table" -import { toast } from "sonner" -import { Calendar, type CalendarProps } from "@/components/ui/calendar" -import { Button } from "@/components/ui/button" -import { Portal } from "@/components/ui/portal" -import { - Select, - SelectTrigger, - SelectContent, - SelectGroup, - SelectItem, - SelectValue, -} from "@/components/ui/select" -import { Separator } from "@/components/ui/separator" -import { - Tooltip, - TooltipTrigger, - TooltipContent, -} from "@/components/ui/tooltip" -import { Kbd } from "@/components/kbd" -import { ActionConfirmDialog } from "@/components/ui/action-dialog" - -import { ArrowUp, CheckCircle2, Download, Loader, Trash2, X, CalendarIcon } from "lucide-react" - -import { exportTableToExcel } from "@/lib/export" - -import { RfqWithItemCount, rfqs } from "@/db/schema/rfq" -import { modifyRfqs, removeRfqs } from "../service" - -interface RfqsTableFloatingBarProps { - table: Table<RfqWithItemCount> -} - -/** - * 추가된 로직: - * - 달력(캘린더) 아이콘 버튼 - * - 눌렀을 때 Popover로 Calendar 표시 - * - 날짜 선택 시 Confirm 다이얼로그 → modifyRfqs({ dueDate }) - */ -export function RfqsTableFloatingBar({ table }: RfqsTableFloatingBarProps) { - const rows = table.getFilteredSelectedRowModel().rows - const [isPending, startTransition] = React.useTransition() - const [action, setAction] = React.useState<"update-status" | "export" | "delete" | "update-dueDate">() - const [confirmDialogOpen, setConfirmDialogOpen] = React.useState(false) - - const [confirmProps, setConfirmProps] = React.useState<{ - title: string - description?: string - onConfirm: () => Promise<void> | void - }>({ - title: "", - description: "", - onConfirm: () => {}, - }) - - // 캘린더 Popover 열림 여부 - const [calendarOpen, setCalendarOpen] = React.useState(false) - const [selectedDate, setSelectedDate] = React.useState<Date | null>(null) - - // Clear selection on Escape key press - React.useEffect(() => { - function handleKeyDown(event: KeyboardEvent) { - if (event.key === "Escape") { - table.toggleAllRowsSelected(false) - } - } - window.addEventListener("keydown", handleKeyDown) - return () => window.removeEventListener("keydown", handleKeyDown) - }, [table]) - - function handleDeleteConfirm() { - setAction("delete") - setConfirmProps({ - title: `Delete ${rows.length} RFQ${rows.length > 1 ? "s" : ""}?`, - description: "This action cannot be undone.", - onConfirm: async () => { - startTransition(async () => { - const { error } = await removeRfqs({ - ids: rows.map((row) => row.original.rfqId), - }) - if (error) { - toast.error(error) - return - } - toast.success("RFQs deleted") - table.toggleAllRowsSelected(false) - setConfirmDialogOpen(false) - }) - }, - }) - setConfirmDialogOpen(true) - } - - function handleSelectStatus(newStatus: RfqWithItemCount["status"]) { - setAction("update-status") - setConfirmProps({ - title: `Update ${rows.length} RFQ${rows.length > 1 ? "s" : ""} with status: ${newStatus}?`, - description: "This action will override their current status.", - onConfirm: async () => { - startTransition(async () => { - const { error } = await modifyRfqs({ - ids: rows.map((row) => row.original.rfqId), - status: newStatus as "DRAFT" | "PUBLISHED" | "EVALUATION" | "AWARDED", - }) - if (error) { - toast.error(error) - return - } - toast.success("RFQs updated") - setConfirmDialogOpen(false) - }) - }, - }) - setConfirmDialogOpen(true) - } - - // 1) 달력에서 날짜를 선택했을 때 → Confirm 다이얼로그 - function handleDueDateSelect(newDate: Date) { - setAction("update-dueDate") - - setConfirmProps({ - title: `Update ${rows.length} RFQ${rows.length > 1 ? "s" : ""} Due Date to ${newDate.toDateString()}?`, - description: "This action will override their current due date.", - onConfirm: async () => { - startTransition(async () => { - const { error } = await modifyRfqs({ - ids: rows.map((r) => r.original.rfqId), - dueDate: newDate, - }) - if (error) { - toast.error(error) - return - } - toast.success("Due date updated") - setConfirmDialogOpen(false) - setCalendarOpen(false) - }) - }, - }) - setConfirmDialogOpen(true) - } - - // 2) Export - function handleExport() { - setAction("export") - startTransition(() => { - exportTableToExcel(table, { - excludeColumns: ["select", "actions"], - onlySelected: true, - }) - }) - } - - // Floating bar UI - return ( - <Portal> - <div className="fixed inset-x-0 bottom-10 z-50 mx-auto w-fit px-2.5"> - <div className="w-full overflow-x-auto"> - <div className="mx-auto flex w-fit items-center gap-2 rounded-md border bg-background p-2 text-foreground shadow"> - {/* Selection Info + Clear */} - <div className="flex h-7 items-center rounded-md border border-dashed pl-2.5 pr-1"> - <span className="whitespace-nowrap text-xs"> - {rows.length} selected - </span> - <Separator orientation="vertical" className="ml-2 mr-1" /> - <Tooltip> - <TooltipTrigger asChild> - <Button - variant="ghost" - size="icon" - className="size-5 hover:border" - onClick={() => table.toggleAllRowsSelected(false)} - > - <X className="size-3.5 shrink-0" aria-hidden="true" /> - </Button> - </TooltipTrigger> - <TooltipContent className="flex items-center border bg-accent px-2 py-1 font-semibold text-foreground dark:bg-zinc-900"> - <p className="mr-2">Clear selection</p> - <Kbd abbrTitle="Escape" variant="outline"> - Esc - </Kbd> - </TooltipContent> - </Tooltip> - </div> - - <Separator orientation="vertical" className="hidden h-5 sm:block" /> - - <div className="flex items-center gap-1.5"> - {/* 1) Status Update */} - <Select - onValueChange={(value: RfqWithItemCount["status"]) => handleSelectStatus(value)} - > - <Tooltip> - <SelectTrigger asChild> - <TooltipTrigger asChild> - <Button - variant="secondary" - size="icon" - className="size-7 border data-[state=open]:bg-accent data-[state=open]:text-accent-foreground" - disabled={isPending} - > - {isPending && action === "update-status" ? ( - <Loader className="size-3.5 animate-spin" aria-hidden="true" /> - ) : ( - <CheckCircle2 className="size-3.5" aria-hidden="true" /> - )} - </Button> - </TooltipTrigger> - </SelectTrigger> - <TooltipContent className="border bg-accent font-semibold text-foreground dark:bg-zinc-900"> - <p>Update status</p> - </TooltipContent> - </Tooltip> - <SelectContent align="center"> - <SelectGroup> - {rfqs.status.enumValues.map((status) => ( - <SelectItem key={status} value={status} className="capitalize"> - {status} - </SelectItem> - ))} - </SelectGroup> - </SelectContent> - </Select> - - {/* 2) Due Date Update: Calendar Popover */} - <Tooltip> - <TooltipTrigger asChild> - <Button - variant="secondary" - size="icon" - className="size-7 border" - disabled={isPending} - onClick={() => setCalendarOpen((open) => !open)} - > - {isPending && action === "update-dueDate" ? ( - <Loader className="size-3.5 animate-spin" aria-hidden="true" /> - ) : ( - <CalendarIcon className="size-3.5" aria-hidden="true" /> - )} - </Button> - </TooltipTrigger> - <TooltipContent className="border bg-accent font-semibold text-foreground dark:bg-zinc-900"> - <p>Update Due Date</p> - </TooltipContent> - </Tooltip> - - {/* Calendar Popover (간단 구현) */} - {calendarOpen && ( - <div className="absolute bottom-16 z-50 rounded-md border bg-background p-2 shadow"> - <Calendar - mode="single" - selected={selectedDate || new Date()} - onSelect={(date) => { - if (date) { - setSelectedDate(date) - handleDueDateSelect(date) - } - }} - initialFocus - /> - </div> - )} - - {/* 3) Export */} - <Tooltip> - <TooltipTrigger asChild> - <Button - variant="secondary" - size="icon" - className="size-7 border" - onClick={handleExport} - disabled={isPending} - > - {isPending && action === "export" ? ( - <Loader className="size-3.5 animate-spin" aria-hidden="true" /> - ) : ( - <Download className="size-3.5" aria-hidden="true" /> - )} - </Button> - </TooltipTrigger> - <TooltipContent className="border bg-accent font-semibold text-foreground dark:bg-zinc-900"> - <p>Export tasks</p> - </TooltipContent> - </Tooltip> - - {/* 4) Delete */} - <Tooltip> - <TooltipTrigger asChild> - <Button - variant="secondary" - size="icon" - className="size-7 border" - onClick={handleDeleteConfirm} - disabled={isPending} - > - {isPending && action === "delete" ? ( - <Loader className="size-3.5 animate-spin" aria-hidden="true" /> - ) : ( - <Trash2 className="size-3.5" aria-hidden="true" /> - )} - </Button> - </TooltipTrigger> - <TooltipContent className="border bg-accent font-semibold text-foreground dark:bg-zinc-900"> - <p>Delete tasks</p> - </TooltipContent> - </Tooltip> - </div> - </div> - </div> - </div> - - {/* 공용 Confirm Dialog */} - <ActionConfirmDialog - open={confirmDialogOpen} - onOpenChange={setConfirmDialogOpen} - title={confirmProps.title} - description={confirmProps.description} - onConfirm={confirmProps.onConfirm} - isLoading={ - isPending && (action === "delete" || action === "update-status" || action === "update-dueDate") - } - confirmLabel={ - action === "delete" - ? "Delete" - : action === "update-status" - ? "Update" - : action === "update-dueDate" - ? "Update" - : "Confirm" - } - confirmVariant={action === "delete" ? "destructive" : "default"} - /> - </Portal> - ) -}
\ No newline at end of file diff --git a/lib/rfqs-tech/table/rfqs-table-toolbar-actions.tsx b/lib/rfqs-tech/table/rfqs-table-toolbar-actions.tsx deleted file mode 100644 index 15306ecf..00000000 --- a/lib/rfqs-tech/table/rfqs-table-toolbar-actions.tsx +++ /dev/null @@ -1,52 +0,0 @@ -"use client" - -import * as React from "react" -import type { Table } from "@tanstack/react-table" -import { Download } from "lucide-react" - -import { exportTableToExcel } from "@/lib/export" -import { Button } from "@/components/ui/button" -import { RfqWithItemCount } from "@/db/schema/rfq" -import { DeleteRfqsDialog } from "./delete-rfqs-dialog" -import { AddRfqDialog } from "./add-rfq-dialog" - - -interface RfqsTableToolbarActionsProps { - table: Table<RfqWithItemCount> -} - -export function RfqsTableToolbarActions({ table }: RfqsTableToolbarActionsProps) { - return ( - <div className="flex items-center gap-2"> - {/** 1) 선택된 로우가 있으면 삭제 다이얼로그 */} - {table.getFilteredSelectedRowModel().rows.length > 0 ? ( - <DeleteRfqsDialog - rfqs={table - .getFilteredSelectedRowModel() - .rows.map((row) => row.original)} - onSuccess={() => table.toggleAllRowsSelected(false)} - /> - ) : null} - - {/** 2) 새 Task 추가 다이얼로그 */} - <AddRfqDialog /> - - - {/** 4) Export 버튼 */} - <Button - variant="outline" - size="sm" - onClick={() => - exportTableToExcel(table, { - filename: "tasks", - excludeColumns: ["select", "actions"], - }) - } - className="gap-2" - > - <Download className="size-4" aria-hidden="true" /> - <span className="hidden sm:inline">Export</span> - </Button> - </div> - ) -}
\ No newline at end of file diff --git a/lib/rfqs-tech/table/rfqs-table.tsx b/lib/rfqs-tech/table/rfqs-table.tsx deleted file mode 100644 index 949f49e9..00000000 --- a/lib/rfqs-tech/table/rfqs-table.tsx +++ /dev/null @@ -1,254 +0,0 @@ -"use client" - -import * as React from "react" -import type { - DataTableAdvancedFilterField, - DataTableFilterField, - DataTableRowAction, -} from "@/types/table" - -import { toSentenceCase } from "@/lib/utils" -import { useDataTable } from "@/hooks/use-data-table" -import { DataTable } from "@/components/data-table/data-table" -import { DataTableAdvancedToolbar } from "@/components/data-table/data-table-advanced-toolbar" - -import { getRFQStatusIcon } from "@/lib/tasks/utils" -import { useFeatureFlags } from "./feature-flags-provider" -import { getColumns } from "./rfqs-table-columns" -import { fetchRfqAttachments, fetchRfqItems, getRfqs, getRfqStatusCounts } from "../service" -import { RfqWithItemCount, rfqs } from "@/db/schema/rfq" -import { UpdateRfqSheet } from "./update-rfq-sheet" -import { DeleteRfqsDialog } from "./delete-rfqs-dialog" -import { RfqsTableToolbarActions } from "./rfqs-table-toolbar-actions" -import { RfqsItemsDialog } from "./ItemsDialog" -import { getAllOffshoreItems } from "@/lib/items-tech/service" -import { RfqAttachmentsSheet } from "./attachment-rfq-sheet" -import { useRouter } from "next/navigation" - -interface RfqsTableProps { - promises: Promise< - [ - Awaited<ReturnType<typeof getRfqs>>, - Awaited<ReturnType<typeof getRfqStatusCounts>>, - Awaited<ReturnType<typeof getAllOffshoreItems>>, - ] - >; -} - -export interface ExistingAttachment { - id: number; - fileName: string; - filePath: string; - createdAt?: Date; - vendorId?: number | null; - size?: number; -} - -export interface ExistingItem { - id?: number; - itemCode: string; - description: string | null; - quantity: number | null; - uom: string | null; -} - -export function RfqsTable({ promises }: RfqsTableProps) { - - const [{ data, pageCount }, statusCounts, items] = React.use(promises) - const [attachmentsOpen, setAttachmentsOpen] = React.useState(false) - const [selectedRfqIdForAttachments, setSelectedRfqIdForAttachments] = React.useState<number | null>(null) - const [attachDefault, setAttachDefault] = React.useState<ExistingAttachment[]>([]) - const [itemsDefault, setItemsDefault] = React.useState<ExistingItem[]>([]) - - const router = useRouter() - - const itemsList = items?.map((v) => ({ - code: v.itemCode ?? "", - itemList: v.itemList ?? "", - subItemList: v.subItemList ?? "", - })); - - const [rowAction, setRowAction] = - React.useState<DataTableRowAction<RfqWithItemCount> | null>(null) - - const [rowData, setRowData] = React.useState<RfqWithItemCount[]>(() => data) - - const [itemsModalOpen, setItemsModalOpen] = React.useState(false); - const [selectedRfqId, setSelectedRfqId] = React.useState<number | null>(null); - - - const selectedRfq = React.useMemo(() => { - return rowData.find(row => row.rfqId === selectedRfqId) || null; - }, [rowData, selectedRfqId]); - - - - async function openItemsModal(rfqId: number) { - const itemList = await fetchRfqItems(rfqId) - setItemsDefault(itemList) - setSelectedRfqId(rfqId); - setItemsModalOpen(true); - } - - async function openAttachmentsSheet(rfqId: number) { - // 4.1) Fetch current attachments from server (server action) - const list = await fetchRfqAttachments(rfqId) // returns ExistingAttachment[] - setAttachDefault(list) - setSelectedRfqIdForAttachments(rfqId) - setAttachmentsOpen(true) - setSelectedRfqId(rfqId); - } - - function handleAttachmentsUpdated(rfqId: number, newCount: number, newList?: ExistingAttachment[]) { - // 5.1) update rowData itemCount - setRowData(prev => - prev.map(r => - r.rfqId === rfqId - ? { ...r, itemCount: newCount } - : r - ) - ) - // 5.2) if newList is provided, store it - if (newList) { - setAttachDefault(newList) - } - } - - const columns = React.useMemo(() => getColumns({ - setRowAction, router, - // we pass openItemsModal as a prop so the itemsColumn can call it - openItemsModal, - openAttachmentsSheet, - }), [setRowAction, router]); - - /** - * This component can render either a faceted filter or a search filter based on the `options` prop. - */ - const filterFields: DataTableFilterField<RfqWithItemCount>[] = [ - { - id: "rfqCode", - label: "RFQ Code", - placeholder: "Filter RFQ Code...", - }, - { - id: "status", - label: "Status", - options: rfqs.status.enumValues?.map((status) => { - // 명시적으로 status를 허용된 리터럴 타입으로 변환 - const s = status as "DRAFT" | "PUBLISHED" | "EVALUATION" | "AWARDED"; - return { - label: toSentenceCase(s), - value: s, - icon: getRFQStatusIcon(s), - count: statusCounts[s], - }; - }), - - } - ] - - /** - * Advanced filter fields for the data table. - */ - const advancedFilterFields: DataTableAdvancedFilterField<RfqWithItemCount>[] = [ - { - id: "rfqCode", - label: "RFQ Code", - type: "text", - }, - { - id: "description", - label: "Description", - type: "text", - }, - { - id: "projectCode", - label: "Project Code", - type: "text", - }, - { - id: "dueDate", - label: "Due Date", - type: "date", - }, - { - id: "status", - label: "Status", - type: "multi-select", - options: rfqs.status.enumValues?.map((status) => { - // 명시적으로 status를 허용된 리터럴 타입으로 변환 - const s = status as "DRAFT" | "PUBLISHED" | "EVALUATION" | "AWARDED"; - return { - label: toSentenceCase(s), - value: s, - icon: getRFQStatusIcon(s), - count: statusCounts[s], - }; - }), - - }, - ] - - const { table } = useDataTable({ - data, - columns, - pageCount, - filterFields, - enablePinning: true, - enableAdvancedFilter: true, - initialState: { - sorting: [{ id: "createdAt", desc: true }], - columnPinning: { right: ["actions"] }, - }, - getRowId: (originalRow) => String(originalRow.rfqId), - shallow: false, - clearOnDefault: true, - }) - - return ( - <div style={{ maxWidth: '100vw' }}> - <DataTable - table={table} - // floatingBar={<RfqsTableFloatingBar table={table} />} - > - <DataTableAdvancedToolbar - table={table} - filterFields={advancedFilterFields} - shallow={false} - > - <RfqsTableToolbarActions table={table} /> - </DataTableAdvancedToolbar> - </DataTable> - - <UpdateRfqSheet - open={rowAction?.type === "update"} - onOpenChange={() => setRowAction(null)} - rfq={rowAction?.row.original ?? null} - /> - - <DeleteRfqsDialog - open={rowAction?.type === "delete"} - onOpenChange={() => setRowAction(null)} - rfqs={rowAction?.row.original ? [rowAction?.row.original] : []} - showTrigger={false} - onSuccess={() => rowAction?.row.toggleSelected(false)} - /> - - <RfqsItemsDialog - open={itemsModalOpen} - onOpenChange={setItemsModalOpen} - rfq={selectedRfq ?? null} - itemsList={itemsList} - defaultItems={itemsDefault} - /> - - <RfqAttachmentsSheet - open={attachmentsOpen} - onOpenChange={setAttachmentsOpen} - defaultAttachments={attachDefault} - rfq={selectedRfq ?? null} - onAttachmentsUpdated={handleAttachmentsUpdated} - /> - </div> - ) -}
\ No newline at end of file diff --git a/lib/rfqs-tech/table/update-rfq-sheet.tsx b/lib/rfqs-tech/table/update-rfq-sheet.tsx deleted file mode 100644 index 9517bc89..00000000 --- a/lib/rfqs-tech/table/update-rfq-sheet.tsx +++ /dev/null @@ -1,243 +0,0 @@ -"use client" - -import * as React from "react" -import { zodResolver } from "@hookform/resolvers/zod" -import { useForm } from "react-hook-form" -import { toast } from "sonner" - -import { Button } from "@/components/ui/button" -import { - Form, - FormControl, - FormField, - FormItem, - FormLabel, - FormMessage, -} from "@/components/ui/form" -import { Input } from "@/components/ui/input" -import { - Sheet, - SheetClose, - SheetContent, - SheetDescription, - SheetFooter, - SheetHeader, - SheetTitle, -} from "@/components/ui/sheet" -import { - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue, -} from "@/components/ui/select" - -import { updateRfqSchema, type UpdateRfqSchema } from "../validations" -import { modifyRfq } from "../service" -import { RfqWithItemCount } from "@/db/schema/rfq" -import { useSession } from "next-auth/react" -import { ProjectSelector } from "@/components/ProjectSelector" -import { Project } from "../service" - -interface UpdateRfqSheetProps - extends React.ComponentPropsWithRef<typeof Sheet> { - rfq: RfqWithItemCount | null -} - -export function UpdateRfqSheet({ rfq, ...props }: UpdateRfqSheetProps) { - const { data: session } = useSession() - const userId = Number(session?.user?.id || 1) - - // RHF setup - const form = useForm<UpdateRfqSchema>({ - resolver: zodResolver(updateRfqSchema), - defaultValues: { - id: rfq?.rfqId ?? 0, // PK - rfqCode: rfq?.rfqCode ?? "", - description: rfq?.description ?? "", - projectId: rfq?.projectId, // 프로젝트 ID - dueDate: rfq?.dueDate ?? undefined, // null을 undefined로 변환 - status: rfq?.status ?? "DRAFT", - createdBy: rfq?.createdBy ?? userId, - }, - }); - - // 프로젝트 선택 처리 - const handleProjectSelect = (project: Project | null) => { - if (project === null) { - return; - } - form.setValue("projectId", project.id); - }; - - async function onSubmit(input: UpdateRfqSchema) { - const { error } = await modifyRfq({ - ...input, - }) - - if (error) { - toast.error(error) - return - } - - form.reset() - props.onOpenChange?.(false) // close the sheet - toast.success("RFQ updated!") - } - - return ( - <Sheet {...props}> - <SheetContent className="flex flex-col gap-6 sm:max-w-md"> - <SheetHeader className="text-left"> - <SheetTitle>Update RFQ</SheetTitle> - <SheetDescription> - Update the RFQ details and save the changes - </SheetDescription> - </SheetHeader> - - {/* RHF Form */} - <Form {...form}> - <form onSubmit={form.handleSubmit(onSubmit)} className="flex flex-col gap-4"> - - {/* Hidden or code-based id field */} - <FormField - control={form.control} - name="id" - render={({ field }) => ( - <input type="hidden" {...field} /> - )} - /> - - {/* Project Selector - 재사용 컴포넌트 사용 */} - <FormField - control={form.control} - name="projectId" - render={({ field }) => ( - <FormItem> - <FormLabel>Project</FormLabel> - <FormControl> - <ProjectSelector - selectedProjectId={field.value} - onProjectSelect={handleProjectSelect} - placeholder="프로젝트 선택..." - /> - </FormControl> - <FormMessage /> - </FormItem> - )} - /> - - {/* rfqCode */} - <FormField - control={form.control} - name="rfqCode" - render={({ field }) => ( - <FormItem> - <FormLabel>RFQ Code</FormLabel> - <FormControl> - <Input placeholder="e.g. RFQ-2025-001" {...field} /> - </FormControl> - <FormMessage /> - </FormItem> - )} - /> - - {/* description */} - <FormField - control={form.control} - name="description" - render={({ field }) => ( - <FormItem> - <FormLabel>Description</FormLabel> - <FormControl> - <Input placeholder="Description" {...field} value={field.value || ""} /> - </FormControl> - <FormMessage /> - </FormItem> - )} - /> - - {/* dueDate */} - <FormField - control={form.control} - name="dueDate" - render={({ field }) => ( - <FormItem> - <FormLabel>Due Date</FormLabel> - <FormControl> - <Input - type="date" - // convert Date -> yyyy-mm-dd - value={field.value ? field.value.toISOString().slice(0, 10) : ""} - onChange={(e) => { - const val = e.target.value - field.onChange(val ? new Date(val + "T00:00:00") : undefined) - }} - /> - </FormControl> - <FormMessage /> - </FormItem> - )} - /> - - {/* status (Select) */} - <FormField - control={form.control} - name="status" - render={({ field }) => ( - <FormItem> - <FormLabel>Status</FormLabel> - <FormControl> - <Select - onValueChange={field.onChange} - value={field.value ?? "DRAFT"} - > - <SelectTrigger className="capitalize"> - <SelectValue placeholder="Select status" /> - </SelectTrigger> - <SelectContent> - <SelectItem key="DRAFT" value="DRAFT" className="capitalize"> - DRAFT - </SelectItem> - <SelectItem key="PUBLISHED" value="PUBLISHED" className="capitalize"> - PUBLISHED - </SelectItem> - <SelectItem key="EVALUATION" value="EVALUATION" className="capitalize"> - EVALUATION - </SelectItem> - <SelectItem key="AWARDED" value="AWARDED" className="capitalize"> - AWARDED - </SelectItem> - </SelectContent> - </Select> - </FormControl> - <FormMessage /> - </FormItem> - )} - /> - - {/* createdBy (hidden or read-only) */} - <FormField - control={form.control} - name="createdBy" - render={({ field }) => ( - <input type="hidden" {...field} /> - )} - /> - - <SheetFooter className="gap-2 pt-2 sm:space-x-0"> - <SheetClose asChild> - <Button type="button" variant="outline"> - Cancel - </Button> - </SheetClose> - <Button> - Save - </Button> - </SheetFooter> - </form> - </Form> - </SheetContent> - </Sheet> - ) -}
\ No newline at end of file diff --git a/lib/rfqs-tech/tbe-table/comments-sheet.tsx b/lib/rfqs-tech/tbe-table/comments-sheet.tsx deleted file mode 100644 index 6efd631f..00000000 --- a/lib/rfqs-tech/tbe-table/comments-sheet.tsx +++ /dev/null @@ -1,325 +0,0 @@ -"use client" - -import * as React from "react" -import { useForm, useFieldArray } from "react-hook-form" -import { z } from "zod" -import { zodResolver } from "@hookform/resolvers/zod" -import { Download, X, Loader2 } from "lucide-react" -import prettyBytes from "pretty-bytes" -import { toast } from "sonner" - -import { - Sheet, - SheetClose, - SheetContent, - SheetDescription, - SheetFooter, - SheetHeader, - SheetTitle, -} from "@/components/ui/sheet" -import { Button } from "@/components/ui/button" -import { - Form, - FormControl, - FormField, - FormItem, - FormLabel, - FormMessage, -} from "@/components/ui/form" -import { Textarea } from "@/components/ui/textarea" -import { - Dropzone, - DropzoneZone, - DropzoneUploadIcon, - DropzoneTitle, - DropzoneDescription, - DropzoneInput, -} from "@/components/ui/dropzone" -import { - Table, - TableHeader, - TableRow, - TableHead, - TableBody, - TableCell, -} from "@/components/ui/table" - -import { createRfqCommentWithAttachments } from "../service" -import { formatDate } from "@/lib/utils" - - -export interface TbeComment { - id: number - commentText: string - commentedBy?: number - commentedByEmail?: string - createdAt?: Date - attachments?: { - id: number - fileName: string - filePath: string - }[] -} - -// 1) props 정의 -interface CommentSheetProps extends React.ComponentPropsWithRef<typeof Sheet> { - initialComments?: TbeComment[] - currentUserId: number - rfqId: number - tbeId: number - vendorId: number - onCommentsUpdated?: (comments: TbeComment[]) => void - isLoading?: boolean // New prop -} - -// 2) 폼 스키마 -const commentFormSchema = z.object({ - commentText: z.string().min(1, "댓글을 입력하세요."), - newFiles: z.array(z.any()).optional(), // File[] -}) -type CommentFormValues = z.infer<typeof commentFormSchema> - -const MAX_FILE_SIZE = 30e6 // 30MB - -export function CommentSheet({ - rfqId, - vendorId, - initialComments = [], - currentUserId, - tbeId, - onCommentsUpdated, - isLoading = false, // Default to false - ...props -}: CommentSheetProps) { - console.log("tbeId", tbeId) - - const [comments, setComments] = React.useState<TbeComment[]>(initialComments) - const [isPending, startTransition] = React.useTransition() - - React.useEffect(() => { - setComments(initialComments) - }, [initialComments]) - - const form = useForm<CommentFormValues>({ - resolver: zodResolver(commentFormSchema), - defaultValues: { - commentText: "", - newFiles: [], - }, - }) - - const { fields: newFileFields, append, remove } = useFieldArray({ - control: form.control, - name: "newFiles", - }) - - // (A) 기존 코멘트 렌더링 - function renderExistingComments() { - - if (isLoading) { - return ( - <div className="flex justify-center items-center h-32"> - <Loader2 className="h-4 w-4 animate-spin text-muted-foreground" /> - <span className="ml-2 text-sm text-muted-foreground">Loading comments...</span> - </div> - ) - } - - if (comments.length === 0) { - return <p className="text-sm text-muted-foreground">No comments yet</p> - } - return ( - <Table> - <TableHeader> - <TableRow> - <TableHead className="w-1/2">Comment</TableHead> - <TableHead>Attachments</TableHead> - <TableHead>Created At</TableHead> - <TableHead>Created By</TableHead> - </TableRow> - </TableHeader> - <TableBody> - {comments.map((c) => ( - <TableRow key={c.id}> - <TableCell>{c.commentText}</TableCell> - <TableCell> - {!c.attachments?.length && ( - <span className="text-sm text-muted-foreground">No files</span> - )} - {c.attachments?.length && ( - <div className="flex flex-col gap-1"> - {c.attachments.map((att) => ( - <div key={att.id} className="flex items-center gap-2"> - <a - href={`/api/rfq-download?path=${encodeURIComponent(att.filePath)}`} - download - target="_blank" - rel="noreferrer" - className="inline-flex items-center gap-1 text-blue-600 underline" - > - <Download className="h-4 w-4" /> - {att.fileName} - </a> - </div> - ))} - </div> - )} - </TableCell> - <TableCell> {c.createdAt ? formatDate(c.createdAt) : "-"}</TableCell> - <TableCell>{c.commentedByEmail ?? "-"}</TableCell> - </TableRow> - ))} - </TableBody> - </Table> - ) - } - - // (B) 파일 드롭 - function handleDropAccepted(files: File[]) { - append(files) - } - - // (C) Submit - async function onSubmit(data: CommentFormValues) { - if (!rfqId) return - startTransition(async () => { - try { - console.log("rfqId", rfqId) - console.log("vendorId", vendorId) - console.log("tbeId", tbeId) - console.log("currentUserId", currentUserId) - const res = await createRfqCommentWithAttachments({ - rfqId, - vendorId, - commentText: data.commentText, - commentedBy: currentUserId, - evaluationId: tbeId, - cbeId: null, - files: data.newFiles, - }) - - if (!res.ok) { - throw new Error("Failed to create comment") - } - - toast.success("Comment created") - - // 임시로 새 코멘트 추가 - const newComment: TbeComment = { - id: res.commentId, // 서버 응답 - commentText: data.commentText, - commentedBy: currentUserId, - createdAt: res.createdAt, - attachments: - data.newFiles?.map((f) => ({ - id: Math.floor(Math.random() * 1e6), - fileName: f.name, - filePath: "/uploads/" + f.name, - })) || [], - } - setComments((prev) => [...prev, newComment]) - onCommentsUpdated?.([...comments, newComment]) - - form.reset() - } catch (err: any) { - console.error(err) - toast.error("Error: " + err.message) - } - }) - } - - return ( - <Sheet {...props}> - <SheetContent className="flex flex-col gap-6 sm:max-w-lg"> - <SheetHeader className="text-left"> - <SheetTitle>Comments</SheetTitle> - <SheetDescription> - 필요시 첨부파일과 함께 문의/코멘트를 남길 수 있습니다. - </SheetDescription> - </SheetHeader> - - <div className="max-h-[300px] overflow-y-auto">{renderExistingComments()}</div> - - <Form {...form}> - <form onSubmit={form.handleSubmit(onSubmit)} className="flex flex-col gap-4"> - <FormField - control={form.control} - name="commentText" - render={({ field }) => ( - <FormItem> - <FormLabel>New Comment</FormLabel> - <FormControl> - <Textarea placeholder="Enter your comment..." {...field} /> - </FormControl> - <FormMessage /> - </FormItem> - )} - /> - - <Dropzone - maxSize={MAX_FILE_SIZE} - onDropAccepted={handleDropAccepted} - onDropRejected={(rej) => { - toast.error("File rejected: " + (rej[0]?.file?.name || "")) - }} - > - {({ maxSize }) => ( - <DropzoneZone className="flex justify-center"> - <DropzoneInput /> - <div className="flex items-center gap-6"> - <DropzoneUploadIcon /> - <div className="grid gap-0.5"> - <DropzoneTitle>Drop to attach files</DropzoneTitle> - <DropzoneDescription> - Max size: {prettyBytes(maxSize || 0)} - </DropzoneDescription> - </div> - </div> - </DropzoneZone> - )} - </Dropzone> - - {newFileFields.length > 0 && ( - <div className="flex flex-col gap-2"> - {newFileFields.map((field, idx) => { - const file = form.getValues(`newFiles.${idx}`) - if (!file) return null - return ( - <div - key={field.id} - className="flex items-center justify-between border rounded p-2" - > - <span className="text-sm"> - {file.name} ({prettyBytes(file.size)}) - </span> - <Button - variant="ghost" - size="icon" - type="button" - onClick={() => remove(idx)} - > - <X className="h-4 w-4" /> - </Button> - </div> - ) - })} - </div> - )} - - <SheetFooter className="gap-2 pt-4"> - <SheetClose asChild> - <Button type="button" variant="outline"> - Cancel - </Button> - </SheetClose> - <Button disabled={isPending}> - {isPending && <Loader2 className="mr-2 h-4 w-4 animate-spin" />} - Save - </Button> - </SheetFooter> - </form> - </Form> - </SheetContent> - </Sheet> - ) -}
\ No newline at end of file diff --git a/lib/rfqs-tech/tbe-table/feature-flags-provider.tsx b/lib/rfqs-tech/tbe-table/feature-flags-provider.tsx deleted file mode 100644 index 81131894..00000000 --- a/lib/rfqs-tech/tbe-table/feature-flags-provider.tsx +++ /dev/null @@ -1,108 +0,0 @@ -"use client" - -import * as React from "react" -import { useQueryState } from "nuqs" - -import { dataTableConfig, type DataTableConfig } from "@/config/data-table" -import { cn } from "@/lib/utils" -import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group" -import { - Tooltip, - TooltipContent, - TooltipTrigger, -} from "@/components/ui/tooltip" - -type FeatureFlagValue = DataTableConfig["featureFlags"][number]["value"] - -interface FeatureFlagsContextProps { - featureFlags: FeatureFlagValue[] - setFeatureFlags: (value: FeatureFlagValue[]) => void -} - -const FeatureFlagsContext = React.createContext<FeatureFlagsContextProps>({ - featureFlags: [], - setFeatureFlags: () => {}, -}) - -export function useFeatureFlags() { - const context = React.useContext(FeatureFlagsContext) - if (!context) { - throw new Error( - "useFeatureFlags must be used within a FeatureFlagsProvider" - ) - } - return context -} - -interface FeatureFlagsProviderProps { - children: React.ReactNode -} - -export function FeatureFlagsProvider({ children }: FeatureFlagsProviderProps) { - const [featureFlags, setFeatureFlags] = useQueryState<FeatureFlagValue[]>( - "flags", - { - defaultValue: [], - parse: (value) => value.split(",") as FeatureFlagValue[], - serialize: (value) => value.join(","), - eq: (a, b) => - a.length === b.length && a.every((value, index) => value === b[index]), - clearOnDefault: true, - shallow: false, - } - ) - - return ( - <FeatureFlagsContext.Provider - value={{ - featureFlags, - setFeatureFlags: (value) => void setFeatureFlags(value), - }} - > - <div className="w-full overflow-x-auto"> - <ToggleGroup - type="multiple" - variant="outline" - size="sm" - value={featureFlags} - onValueChange={(value: FeatureFlagValue[]) => setFeatureFlags(value)} - className="w-fit gap-0" - > - {dataTableConfig.featureFlags.map((flag, index) => ( - <Tooltip key={flag.value}> - <ToggleGroupItem - value={flag.value} - className={cn( - "gap-2 whitespace-nowrap rounded-none px-3 text-xs data-[state=on]:bg-accent/70 data-[state=on]:hover:bg-accent/90", - { - "rounded-l-sm border-r-0": index === 0, - "rounded-r-sm": - index === dataTableConfig.featureFlags.length - 1, - } - )} - asChild - > - <TooltipTrigger> - <flag.icon className="size-3.5 shrink-0" aria-hidden="true" /> - {flag.label} - </TooltipTrigger> - </ToggleGroupItem> - <TooltipContent - align="start" - side="bottom" - sideOffset={6} - className="flex max-w-60 flex-col space-y-1.5 border bg-background py-2 font-semibold text-foreground" - > - <div>{flag.tooltipTitle}</div> - <div className="text-xs text-muted-foreground"> - {flag.tooltipDescription} - </div> - </TooltipContent> - </Tooltip> - ))} - </ToggleGroup> - </div> - {children} - </FeatureFlagsContext.Provider> - ) -} diff --git a/lib/rfqs-tech/tbe-table/file-dialog.tsx b/lib/rfqs-tech/tbe-table/file-dialog.tsx deleted file mode 100644 index 712f7ff6..00000000 --- a/lib/rfqs-tech/tbe-table/file-dialog.tsx +++ /dev/null @@ -1,139 +0,0 @@ -"use client" - -import * as React from "react" -import { Download, X } from "lucide-react" -import { toast } from "sonner" - -import { getErrorMessage } from "@/lib/handle-error" -import { formatDateTime } from "@/lib/utils" -import { Button } from "@/components/ui/button" -import { - Dialog, - DialogContent, - DialogDescription, - DialogHeader, - DialogTitle, -} from "@/components/ui/dialog" - -import { - FileList, - FileListItem, - FileListIcon, - FileListInfo, - FileListName, - FileListDescription, - FileListAction, -} from "@/components/ui/file-list" -import { getTbeFilesForVendor, getTbeSubmittedFiles } from "../service" - -interface TBEFileDialogProps { - isOpen: boolean - onOpenChange: (open: boolean) => void - tbeId: number - vendorId: number - rfqId: number - onRefresh?: () => void -} - -export function TBEFileDialog({ - isOpen, - onOpenChange, - vendorId, - rfqId, - onRefresh, -}: TBEFileDialogProps) { - const [submittedFiles, setSubmittedFiles] = React.useState<any[]>([]) - const [isFetchingFiles, setIsFetchingFiles] = React.useState(false) - - - // Fetch submitted files when dialog opens - React.useEffect(() => { - if (isOpen && rfqId && vendorId) { - fetchSubmittedFiles() - } - }, [isOpen, rfqId, vendorId]) - - // Fetch submitted files using the service function - const fetchSubmittedFiles = async () => { - if (!rfqId || !vendorId) return - - setIsFetchingFiles(true) - try { - const { files, error } = await getTbeFilesForVendor(rfqId, vendorId) - - if (error) { - throw new Error(error) - } - - setSubmittedFiles(files) - } catch (error) { - toast.error("Failed to load files: " + getErrorMessage(error)) - } finally { - setIsFetchingFiles(false) - } - } - - // Download submitted file - const downloadSubmittedFile = async (file: any) => { - try { - const response = await fetch(`/api/tbe-download?path=${encodeURIComponent(file.filePath)}`) - if (!response.ok) { - throw new Error("Failed to download file") - } - - const blob = await response.blob() - const url = window.URL.createObjectURL(blob) - const a = document.createElement("a") - a.href = url - a.download = file.fileName - document.body.appendChild(a) - a.click() - window.URL.revokeObjectURL(url) - document.body.removeChild(a) - } catch (error) { - toast.error("Failed to download file: " + getErrorMessage(error)) - } - } - - return ( - <Dialog open={isOpen} onOpenChange={onOpenChange}> - <DialogContent className="sm:max-w-lg"> - <DialogHeader> - <DialogTitle>TBE 응답 파일</DialogTitle> - <DialogDescription>제출된 파일 목록을 확인하고 다운로드하세요.</DialogDescription> - </DialogHeader> - - {/* 제출된 파일 목록 */} - {isFetchingFiles ? ( - <div className="flex justify-center items-center py-8"> - <div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary"></div> - </div> - ) : submittedFiles.length > 0 ? ( - <div className="grid gap-2"> - <FileList> - {submittedFiles.map((file) => ( - <FileListItem key={file.id} className="flex items-center justify-between gap-3"> - <div className="flex items-center gap-3 flex-1"> - <FileListIcon className="flex-shrink-0" /> - <FileListInfo className="flex-1 min-w-0"> - <FileListName className="text-sm font-medium truncate">{file.fileName}</FileListName> - <FileListDescription className="text-xs text-muted-foreground"> - {file.uploadedAt ? formatDateTime(file.uploadedAt) : ""} - </FileListDescription> - </FileListInfo> - </div> - <FileListAction className="flex-shrink-0 ml-2" onClick={() => downloadSubmittedFile(file)}> - <Download className="h-4 w-4" /> - <span className="sr-only">파일 다운로드</span> - </FileListAction> - </FileListItem> - ))} - </FileList> - </div> - ) : ( - <div className="text-center py-8 text-muted-foreground">제출된 파일이 없습니다.</div> - )} - </DialogContent> - </Dialog> - ) -}
\ No newline at end of file diff --git a/lib/rfqs-tech/tbe-table/invite-vendors-dialog.tsx b/lib/rfqs-tech/tbe-table/invite-vendors-dialog.tsx deleted file mode 100644 index f7aa957c..00000000 --- a/lib/rfqs-tech/tbe-table/invite-vendors-dialog.tsx +++ /dev/null @@ -1,227 +0,0 @@ -"use client" - -import * as React from "react" -import { type Row } from "@tanstack/react-table" -import { Loader, Send } 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 { Input } from "@/components/ui/input" - -import { VendorWithTbeFields } from "@/config/vendorTbeColumnsConfig" -import { inviteTbeVendorsAction } from "../service" -import { ScrollArea } from "@/components/ui/scroll-area" -import { Badge } from "@/components/ui/badge" -import { Label } from "@/components/ui/label" - -interface InviteVendorsDialogProps - extends React.ComponentPropsWithoutRef<typeof Dialog> { - vendors: Row<VendorWithTbeFields>["original"][] - rfqId: number - showTrigger?: boolean - onSuccess?: () => void -} - -export function InviteVendorsDialog({ - vendors, - rfqId, - showTrigger = true, - onSuccess, - ...props -}: InviteVendorsDialogProps) { - const [isInvitePending, startInviteTransition] = React.useTransition() - - - // multiple 파일을 받을 state - const [files, setFiles] = React.useState<FileList | null>(null) - - // 미디어쿼리 (desktop 여부) - const isDesktop = useMediaQuery("(min-width: 640px)") - - function onInvite() { - startInviteTransition(async () => { - // 파일이 선택되지 않았다면 에러 - if (!files || files.length === 0) { - toast.error("Please attach TBE files before inviting.") - return - } - - // FormData 생성 - const formData = new FormData() - formData.append("rfqId", String(rfqId)) - - console.log("Invite Debug:", { - rfqId, - vendors, - files - }) - - vendors.forEach((vendor) => { - formData.append("vendorIds[]", String(vendor.id)) - }) - - // multiple 파일 - for (let i = 0; i < files.length; i++) { - formData.append("tbeFiles", files[i]) // key는 동일하게 "tbeFiles" - } - - // 서버 액션 호출 - const { error } = await inviteTbeVendorsAction(formData) - - if (error) { - toast.error(error) - return - } - - // 성공 - props.onOpenChange?.(false) - toast.success("Vendors invited with TBE!") - onSuccess?.() - }) - } - - // 파일 선택 UI - const fileInput = ( -<> - <div className="space-y-2"> - <Label>선택된 협력업체 ({vendors.length})</Label> - <ScrollArea className="h-20 border rounded-md p-2"> - <div className="flex flex-wrap gap-2"> - {vendors.map((vendor, index) => ( - <Badge key={index} variant="secondary" className="py-1"> - {vendor.vendorName || `협력업체 #${vendor.vendorCode}`} - </Badge> - ))} - </div> - </ScrollArea> - <p className="text-[0.8rem] font-medium text-muted-foreground"> - 선택된 모든 협력업체의 등록된 연락처에게 TBE 평가 알림이 전송됩니다. - </p> - </div> - - <div className="mb-4"> - <label className="mb-2 block font-medium">TBE Sheets</label> - <Input - type="file" - multiple - onChange={(e) => { - setFiles(e.target.files) - }} - /> - </div> - </> - ) - - // Desktop Dialog - if (isDesktop) { - return ( - <Dialog {...props}> - {showTrigger ? ( - <DialogTrigger asChild> - <Button variant="outline" size="sm"> - <Send className="mr-2 size-4" aria-hidden="true" /> - TBE 평가 생성 ({vendors.length}) - </Button> - </DialogTrigger> - ) : null} - <DialogContent> - <DialogHeader> - <DialogTitle>TBE 평가 시트 전송</DialogTitle> - <DialogDescription> - 선택한 {vendors.length}개 협력업체에 대한 기술 평가 시트와 알림을 전송합니다. 파일 첨부가 필수이므로 파일을 첨부해야지 버튼이 활성화됩니다. - </DialogDescription> - </DialogHeader> - - {/* 파일 첨부 */} - {fileInput} - - <DialogFooter className="gap-2 sm:space-x-0"> - <DialogClose asChild> - <Button variant="outline">Cancel</Button> - </DialogClose> - <Button - aria-label="Invite selected rows" - variant="destructive" - onClick={onInvite} - // 파일이 없거나 초대 진행중이면 비활성화 - disabled={isInvitePending || !files || files.length === 0} - > - {isInvitePending && ( - <Loader - className="mr-2 size-4 animate-spin" - aria-hidden="true" - /> - )} - Invite - </Button> - </DialogFooter> - </DialogContent> - </Dialog> - ) - } - - // Mobile Drawer - return ( - <Drawer {...props}> - {showTrigger ? ( - <DrawerTrigger asChild> - <Button variant="outline" size="sm"> - <Send className="mr-2 size-4" aria-hidden="true" /> - Invite ({vendors.length}) - </Button> - </DrawerTrigger> - ) : null} - <DrawerContent> - <DrawerHeader> - <DialogTitle>TBE 평가 시트 전송</DialogTitle> - <DialogDescription> - 선택한 {vendors.length}개 협력업체에 대한 기술 평가 시트와 알림을 전송합니다. 파일 첨부가 필수이므로 파일을 첨부해야지 버튼이 활성화됩니다. - </DialogDescription> - </DrawerHeader> - - {/* 파일 첨부 */} - {fileInput} - - <DrawerFooter className="gap-2 sm:space-x-0"> - <DrawerClose asChild> - <Button variant="outline">Cancel</Button> - </DrawerClose> - <Button - aria-label="Invite selected rows" - variant="destructive" - onClick={onInvite} - // 파일이 없거나 초대 진행중이면 비활성화 - disabled={isInvitePending || !files || files.length === 0} - > - {isInvitePending && ( - <Loader className="mr-2 size-4 animate-spin" aria-hidden="true" /> - )} - Invite - </Button> - </DrawerFooter> - </DrawerContent> - </Drawer> - ) -}
\ No newline at end of file diff --git a/lib/rfqs-tech/tbe-table/tbe-result-dialog.tsx b/lib/rfqs-tech/tbe-table/tbe-result-dialog.tsx deleted file mode 100644 index 6bd8a6a7..00000000 --- a/lib/rfqs-tech/tbe-table/tbe-result-dialog.tsx +++ /dev/null @@ -1,208 +0,0 @@ -"use client" - -import * as React from "react" -import { toast } from "sonner" - -import { Badge } from "@/components/ui/badge" -import { Button } from "@/components/ui/button" -import { - Dialog, - DialogContent, - DialogDescription, - DialogFooter, - DialogHeader, - DialogTitle, -} from "@/components/ui/dialog" -import { - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue, -} from "@/components/ui/select" -import { Textarea } from "@/components/ui/textarea" -import { Label } from "@/components/ui/label" -import { VendorWithTbeFields } from "@/config/vendorTbeColumnsConfig" -import { getErrorMessage } from "@/lib/handle-error" -import { saveTbeResult } from "../service" - -// Define the props for the TbeResultDialog component -interface TbeResultDialogProps { - open: boolean - onOpenChange: (open: boolean) => void - tbe: VendorWithTbeFields | null - onRefresh?: () => void -} - -// Define TBE result options -const TBE_RESULT_OPTIONS = [ - { value: "pass", label: "Pass", badgeVariant: "default" }, - { value: "non-pass", label: "Non-Pass", badgeVariant: "destructive" }, - { value: "conditional pass", label: "Conditional Pass", badgeVariant: "secondary" }, -] as const - -type TbeResultOption = typeof TBE_RESULT_OPTIONS[number]["value"] - -export function TbeResultDialog({ - open, - onOpenChange, - tbe, - onRefresh, -}: TbeResultDialogProps) { - // Initialize state for form inputs - const [result, setResult] = React.useState<TbeResultOption | "">("") - const [note, setNote] = React.useState("") - const [isSubmitting, setIsSubmitting] = React.useState(false) - - // Update form values when the tbe prop changes - React.useEffect(() => { - if (tbe) { - setResult((tbe.tbeResult as TbeResultOption) || "") - setNote(tbe.tbeNote || "") - } - }, [tbe]) - - // Reset form when dialog closes - React.useEffect(() => { - if (!open) { - // Small delay to avoid visual glitches when dialog is closing - const timer = setTimeout(() => { - if (!tbe) { - setResult("") - setNote("") - } - }, 300) - return () => clearTimeout(timer) - } - }, [open, tbe]) - - // Handle form submission with server action - const handleSubmit = async (e: React.FormEvent) => { - e.preventDefault() - - if (!tbe || !result) return - - setIsSubmitting(true) - - try { - // Call the server action to save the TBE result - const response = await saveTbeResult({ - id: tbe.tbeId ?? 0, // This is the id in the rfq_evaluations table - vendorId: tbe.vendorId, // This is the vendorId in the rfq_evaluations table - result: result, // The selected evaluation result - notes: note, // The evaluation notes - }) - - if (!response.success) { - throw new Error(response.message || "Failed to save TBE result") - } - - // Show success toast - toast.success("TBE result saved successfully") - - // Close the dialog - onOpenChange(false) - - // Refresh the data if refresh callback is provided - if (onRefresh) { - onRefresh() - } - } catch (error) { - // Show error toast - toast.error(`Failed to save: ${getErrorMessage(error)}`) - } finally { - setIsSubmitting(false) - } - } - - // Find the selected result option - const selectedOption = TBE_RESULT_OPTIONS.find(option => option.value === result) - - return ( - <Dialog open={open} onOpenChange={onOpenChange}> - <DialogContent className="sm:max-w-[500px]"> - <DialogHeader> - <DialogTitle className="text-xl font-semibold"> - {tbe?.tbeResult ? "Edit TBE Result" : "Enter TBE Result"} - </DialogTitle> - {tbe && ( - <DialogDescription className="text-sm text-muted-foreground mt-1"> - <span className="flex flex-col gap-1"> - <span> - <strong>Vendor:</strong> {tbe.vendorName} - </span> - <span> - <strong>RFQ Code:</strong> {tbe.rfqCode} - </span> - {tbe.email && ( - <span> - <strong>Email:</strong> {tbe.email} - </span> - )} - </span> - </DialogDescription> - )} - </DialogHeader> - - <form onSubmit={handleSubmit} className="space-y-6 py-2"> - <div className="space-y-2"> - <Label htmlFor="tbe-result" className="text-sm font-medium"> - Evaluation Result - </Label> - <Select - value={result} - onValueChange={(value) => setResult(value as TbeResultOption)} - required - > - <SelectTrigger id="tbe-result" className="w-full"> - <SelectValue placeholder="Select a result" /> - </SelectTrigger> - <SelectContent> - {TBE_RESULT_OPTIONS.map((option) => ( - <SelectItem key={option.value} value={option.value}> - <div className="flex items-center"> - <Badge variant={option.badgeVariant as any} className="mr-2"> - {option.label} - </Badge> - </div> - </SelectItem> - ))} - </SelectContent> - </Select> - </div> - - <div className="space-y-2"> - <Label htmlFor="tbe-note" className="text-sm font-medium"> - Evaluation Note - </Label> - <Textarea - id="tbe-note" - placeholder="Enter evaluation notes..." - value={note} - onChange={(e) => setNote(e.target.value)} - className="min-h-[120px] resize-y" - /> - </div> - - <DialogFooter className="gap-2 sm:gap-0"> - <Button - type="button" - variant="outline" - onClick={() => onOpenChange(false)} - disabled={isSubmitting} - > - Cancel - </Button> - <Button - type="submit" - disabled={!result || isSubmitting} - className="min-w-[100px]" - > - {isSubmitting ? "Saving..." : "Save"} - </Button> - </DialogFooter> - </form> - </DialogContent> - </Dialog> - ) -}
\ No newline at end of file diff --git a/lib/rfqs-tech/tbe-table/tbe-table-columns.tsx b/lib/rfqs-tech/tbe-table/tbe-table-columns.tsx deleted file mode 100644 index aecbcdb2..00000000 --- a/lib/rfqs-tech/tbe-table/tbe-table-columns.tsx +++ /dev/null @@ -1,360 +0,0 @@ -"use client" - -import * as React from "react" -import { type DataTableRowAction } from "@/types/table" -import { type ColumnDef } from "@tanstack/react-table" -import { Download, Ellipsis, MessageSquare } from "lucide-react" -import { toast } from "sonner" - -import { getErrorMessage } from "@/lib/handle-error" -import { formatDate } from "@/lib/utils" -import { Badge } from "@/components/ui/badge" -import { Button } from "@/components/ui/button" -import { Checkbox } from "@/components/ui/checkbox" - -import { DataTableColumnHeaderSimple } from "@/components/data-table/data-table-column-simple-header" -import { useRouter } from "next/navigation" - -import { - vendorTbeColumnsConfig, - VendorWithTbeFields, -} from "@/config/vendorTbeColumnsConfig" - -type NextRouter = ReturnType<typeof useRouter> - -interface GetColumnsProps { - setRowAction: React.Dispatch< - React.SetStateAction<DataTableRowAction<VendorWithTbeFields> | null> - > - router: NextRouter - openCommentSheet: (vendorId: number) => void - openFilesDialog: (tbeId:number , vendorId: number) => void - openVendorContactsDialog: (vendorId: number, vendor: VendorWithTbeFields) => void // 수정된 시그니처 - -} - -/** - * tanstack table 컬럼 정의 (중첩 헤더 버전) - */ -export function getColumns({ - setRowAction, - openCommentSheet, - openFilesDialog, - openVendorContactsDialog -}: GetColumnsProps): ColumnDef<VendorWithTbeFields>[] { - // ---------------------------------------------------------------- - // 1) Select 컬럼 (체크박스) - // ---------------------------------------------------------------- - const selectColumn: ColumnDef<VendorWithTbeFields> = { - id: "select", - header: ({ table }) => ( - <Checkbox - checked={ - table.getIsAllPageRowsSelected() || - (table.getIsSomePageRowsSelected() && "indeterminate") - } - onCheckedChange={(value) => table.toggleAllPageRowsSelected(!!value)} - aria-label="Select all" - className="translate-y-0.5" - /> - ), - cell: ({ row }) => ( - <Checkbox - checked={row.getIsSelected()} - onCheckedChange={(value) => row.toggleSelected(!!value)} - aria-label="Select row" - className="translate-y-0.5" - /> - ), - size: 40, - enableSorting: false, - enableHiding: false, - } - - // ---------------------------------------------------------------- - // 2) 그룹화(Nested) 컬럼 구성 - // ---------------------------------------------------------------- - const groupMap: Record<string, ColumnDef<VendorWithTbeFields>[]> = {} - - vendorTbeColumnsConfig.forEach((cfg) => { - const groupName = cfg.group || "_noGroup" - if (!groupMap[groupName]) { - groupMap[groupName] = [] - } - - // childCol: ColumnDef<VendorWithTbeFields> - const childCol: ColumnDef<VendorWithTbeFields> = { - accessorKey: cfg.id, - enableResizing: true, - header: ({ column }) => ( - <DataTableColumnHeaderSimple column={column} title={cfg.label} /> - ), - meta: { - excelHeader: cfg.excelHeader, - group: cfg.group, - type: cfg.type, - }, - // 셀 렌더링 - cell: ({ row, getValue }) => { - // 1) 필드값 가져오기 - const val = getValue() - - if (cfg.id === "vendorName") { - const vendor = row.original; - const vendorId = vendor.vendorId; - - // 협력업체 이름을 클릭할 수 있는 버튼으로 렌더링 - const handleVendorNameClick = () => { - if (vendorId) { - openVendorContactsDialog(vendorId, vendor); // vendor 전체 객체 전달 - } else { - toast.error("협력업체 ID를 찾을 수 없습니다."); - } - }; - - return ( - <Button - variant="link" - className="p-0 h-auto text-left font-normal justify-start hover:underline" - onClick={handleVendorNameClick} - > - {val as string} - </Button> - ); - } - - if (cfg.id === "tbeResult") { - const vendor = row.original; - const tbeResult = vendor.tbeResult; - const filesCount = vendor.files?.length ?? 0; - - // Only show button or link if there are files - if (filesCount > 0) { - // Function to handle clicking on the result - const handleTbeResultClick = () => { - setRowAction({ row, type: "tbeResult" }); - }; - - if (!tbeResult) { - // No result yet, but files exist - show "결과 입력" button - return ( - <Button - variant="outline" - size="sm" - onClick={handleTbeResultClick} - > - 결과 입력 - </Button> - ); - } else { - // Result exists - show as a hyperlink - let badgeVariant: "default" | "outline" | "destructive" | "secondary" = "outline"; - - // Set badge variant based on result - if (tbeResult === "pass") { - badgeVariant = "default"; - } else if (tbeResult === "non-pass") { - badgeVariant = "destructive"; - } else if (tbeResult === "conditional pass") { - badgeVariant = "secondary"; - } - - return ( - <Button - variant="link" - className="p-0 h-auto underline" - onClick={handleTbeResultClick} - > - <Badge variant={badgeVariant}> - {tbeResult} - </Badge> - </Button> - ); - } - } - - // No files available, return empty cell - return null; - } - - - if (cfg.id === "vendorStatus") { - const statusVal = row.original.vendorStatus - if (!statusVal) return null - // const Icon = getStatusIcon(statusVal) - return ( - <Badge variant="outline"> - {statusVal} - </Badge> - ) - } - - - if (cfg.id === "rfqVendorStatus") { - const statusVal = row.original.rfqVendorStatus - if (!statusVal) return null - // const Icon = getStatusIcon(statusVal) - const variant = statusVal ==="INVITED"?"default" :statusVal ==="DECLINED"?"destructive":statusVal ==="ACCEPTED"?"secondary":"outline" - return ( - <Badge variant={variant}> - {statusVal} - </Badge> - ) - } - - - - // 예) TBE Updated (날짜) - if (cfg.id === "tbeUpdated") { - const dateVal = val as Date | undefined - if (!dateVal) return null - return formatDate(dateVal) - } - - // 그 외 필드는 기본 값 표시 - return val ?? "" - }, - } - - groupMap[groupName].push(childCol) - }) - - // groupMap → nestedColumns - const nestedColumns: ColumnDef<VendorWithTbeFields>[] = [] - Object.entries(groupMap).forEach(([groupName, colDefs]) => { - if (groupName === "_noGroup") { - nestedColumns.push(...colDefs) - } else { - nestedColumns.push({ - id: groupName, - header: groupName, - columns: colDefs, - }) - } - }) - -// ---------------------------------------------------------------- -// 3) Comments 컬럼 -// ---------------------------------------------------------------- -const commentsColumn: ColumnDef<VendorWithTbeFields> = { - id: "comments", - header: ({ column }) => ( - <DataTableColumnHeaderSimple column={column} title="Comments" /> - ), - cell: ({ row }) => { - const vendor = row.original - const commCount = vendor.comments?.length ?? 0 - - function handleClick() { - // rowAction + openCommentSheet - setRowAction({ row, type: "comments" }) - openCommentSheet(vendor.tbeId ?? 0) - } - - return ( - <Button - variant="ghost" - size="sm" - className="relative h-8 w-8 p-0 group" - onClick={handleClick} - aria-label={ - commCount > 0 ? `View ${commCount} comments` : "No comments" - } - > - <MessageSquare className="h-4 w-4 text-muted-foreground group-hover:text-primary transition-colors" /> - {commCount > 0 && ( - <Badge - variant="secondary" - className="pointer-events-none absolute -top-1 -right-1 h-4 min-w-[1rem] p-0 text-[0.625rem] leading-none flex items-center justify-center" - > - {commCount} - </Badge> - )} - <span className="sr-only"> - {commCount > 0 ? `${commCount} Comments` : "No Comments"} - </span> - </Button> - ) - }, - enableSorting: false, - maxSize:80 -} - - // ---------------------------------------------------------------- - // 2) 액션컬럼 (빈 상태로 유지하여 에러 방지) - // ---------------------------------------------------------------- - const actionsColumn: ColumnDef<VendorWithTbeFields> = { - id: "actions", - enableHiding: false, - cell: () => { - // 빈 셀 반환 (액션 없음) - return null - }, - size: 40, - enableSorting: false, - } -// ---------------------------------------------------------------- -// 3) Files Column - Add before Comments column -// ---------------------------------------------------------------- -const filesColumn: ColumnDef<VendorWithTbeFields> = { - id: "files", - header: ({ column }) => ( - <DataTableColumnHeaderSimple column={column} title="Response Files" /> - ), - cell: ({ row }) => { - const vendor = row.original - // We'll assume that files count will be populated from the backend - // You'll need to modify your getTBE function to include files - const filesCount = vendor.files?.length ?? 0 - - function handleClick() { - // Open files dialog - setRowAction({ row, type: "files" }) - openFilesDialog(vendor.tbeId ?? 0, vendor.vendorId ?? 0) - } - - return ( - <div className="flex items-center justify-center"> -<Button - variant="ghost" - size="sm" - className="relative h-8 w-8 p-0 group" - onClick={handleClick} - aria-label={filesCount > 0 ? `View ${filesCount} files` : "Upload file"} -> - {/* 아이콘: 중앙 정렬을 위해 Button 자체가 flex container */} - <Download className="h-4 w-4 text-muted-foreground group-hover:text-primary transition-colors" /> - - {/* 파일 개수가 1개 이상이면 뱃지 표시 */} - {filesCount > 0 && ( - <Badge - variant="secondary" - className="pointer-events-none absolute -top-1 -right-1 h-4 min-w-[1rem] p-0 text-[0.625rem] leading-none flex items-center justify-center" - > - {filesCount} - </Badge> - )} - - <span className="sr-only"> - {filesCount > 0 ? `${filesCount} Files` : "Upload File"} - </span> -</Button> - </div> - ) - }, - enableSorting: false, - maxSize: 80 -} - -// ---------------------------------------------------------------- -// 5) 최종 컬럼 배열 - Update to include the files column -// ---------------------------------------------------------------- -return [ - selectColumn, - ...nestedColumns, - filesColumn, // Add the files column before comments - commentsColumn, - actionsColumn, -] - -}
\ No newline at end of file diff --git a/lib/rfqs-tech/tbe-table/tbe-table-toolbar-actions.tsx b/lib/rfqs-tech/tbe-table/tbe-table-toolbar-actions.tsx deleted file mode 100644 index f78e539c..00000000 --- a/lib/rfqs-tech/tbe-table/tbe-table-toolbar-actions.tsx +++ /dev/null @@ -1,67 +0,0 @@ -"use client" - -import * as React from "react" -import { type Table } from "@tanstack/react-table" -import { Download, Upload } from "lucide-react" -import { toast } from "sonner" - -import { exportTableToExcel } from "@/lib/export" -import { Button } from "@/components/ui/button" - - -import { InviteVendorsDialog } from "./invite-vendors-dialog" -import { VendorWithTbeFields } from "@/config/vendorTbeColumnsConfig" - -interface VendorsTableToolbarActionsProps { - table: Table<VendorWithTbeFields> - rfqId: number -} - -export function VendorsTableToolbarActions({ table,rfqId }: VendorsTableToolbarActionsProps) { - // 파일 input을 숨기고, 버튼 클릭 시 참조해 클릭하는 방식 - const fileInputRef = React.useRef<HTMLInputElement>(null) - - // 파일이 선택되었을 때 처리 - - function handleImportClick() { - // 숨겨진 <input type="file" /> 요소를 클릭 - fileInputRef.current?.click() - } - - const invitationPossibeVendors = React.useMemo(() => { - return table - .getFilteredSelectedRowModel() - .rows - .map(row => row.original) - .filter(vendor => vendor.technicalResponseStatus === null); - }, [table.getFilteredSelectedRowModel().rows]); - - return ( - <div className="flex items-center gap-2"> - {invitationPossibeVendors.length > 0 && - ( - <InviteVendorsDialog - vendors={invitationPossibeVendors} - rfqId = {rfqId} - onSuccess={() => table.toggleAllRowsSelected(false)} - /> - ) - } - - <Button - variant="outline" - size="sm" - onClick={() => - exportTableToExcel(table, { - filename: "tasks", - excludeColumns: ["select"], - }) - } - className="gap-2" - > - <Download className="size-4" aria-hidden="true" /> - <span className="hidden sm:inline">Export</span> - </Button> - </div> - ) -}
\ No newline at end of file diff --git a/lib/rfqs-tech/tbe-table/tbe-table.tsx b/lib/rfqs-tech/tbe-table/tbe-table.tsx deleted file mode 100644 index a162edbb..00000000 --- a/lib/rfqs-tech/tbe-table/tbe-table.tsx +++ /dev/null @@ -1,243 +0,0 @@ -"use client" - -import * as React from "react" -import { useRouter } from "next/navigation" -import type { - DataTableAdvancedFilterField, - DataTableFilterField, - DataTableRowAction, -} from "@/types/table" - -import { toSentenceCase } from "@/lib/utils" -import { useDataTable } from "@/hooks/use-data-table" -import { DataTable } from "@/components/data-table/data-table" -import { DataTableAdvancedToolbar } from "@/components/data-table/data-table-advanced-toolbar" -import { getColumns } from "./tbe-table-columns" -import { vendors } from "@/db/schema/vendors" -import { VendorsTableToolbarActions } from "./tbe-table-toolbar-actions" -import { fetchRfqAttachmentsbyCommentId, getTBE } from "../service" -import { InviteVendorsDialog } from "./invite-vendors-dialog" -import { CommentSheet, TbeComment } from "./comments-sheet" -import { VendorWithTbeFields } from "@/config/vendorTbeColumnsConfig" -import { TBEFileDialog } from "./file-dialog" -import { TbeResultDialog } from "./tbe-result-dialog" -import { VendorContactsDialog } from "./vendor-contact-dialog" -import { useSession } from "next-auth/react" // Next-auth session hook 추가 - -interface VendorsTableProps { - promises: Promise< - [ - Awaited<ReturnType<typeof getTBE>>, - ] - > - rfqId: number -} - - -export function TbeTable({ promises, rfqId }: VendorsTableProps) { - // Suspense로 받아온 데이터 - const [{ data: rawData, pageCount }] = React.use(promises) - - // 벤더별로 데이터 그룹화 - const data = React.useMemo(() => { - const vendorMap = new Map<number, VendorWithTbeFields>() - - rawData.forEach((item) => { - const vendorId = item.vendorId - - if (vendorMap.has(vendorId)) { - // 기존 벤더 데이터가 있으면 파일과 댓글을 합침 - const existing = vendorMap.get(vendorId)! - - // 파일 합치기 (중복 제거) - const existingFileIds = new Set(existing.files.map(f => f.id)) - const newFiles = item.files.filter(f => !existingFileIds.has(f.id)) - existing.files = [...existing.files, ...newFiles] - - // 댓글 합치기 (중복 제거) - const existingCommentIds = new Set(existing.comments.map(c => c.id)) - const newComments = item.comments.filter(c => !existingCommentIds.has(c.id)) - existing.comments = [...existing.comments, ...newComments] - - } else { - // 새로운 벤더 데이터 추가 - vendorMap.set(vendorId, { ...item, vendorResponseId: item.id }) - } - }) - - return Array.from(vendorMap.values()) - }, [rawData]) - - const { data: session } = useSession() // 세션 정보 가져오기 - - const currentUserId = session?.user?.id ? parseInt(session.user.id, 10) : 0 - - - const [rowAction, setRowAction] = React.useState<DataTableRowAction<VendorWithTbeFields> | null>(null) - - // **router** 획득 - const router = useRouter() - - const [initialComments, setInitialComments] = React.useState<TbeComment[]>([]) - const [commentSheetOpen, setCommentSheetOpen] = React.useState(false) - - const [isFileDialogOpen, setIsFileDialogOpen] = React.useState(false) - const [selectedVendorId, setSelectedVendorId] = React.useState<number | null>(null) - const [selectedTbeId, setSelectedTbeId] = React.useState<number | null>(null) - const [isContactDialogOpen, setIsContactDialogOpen] = React.useState(false) - const [selectedVendor, setSelectedVendor] = React.useState<VendorWithTbeFields | null>(null) - - // Add handleRefresh function - const handleRefresh = React.useCallback(() => { - router.refresh(); - }, [router]); - - React.useEffect(() => { - if (rowAction?.type === "comments") { - // rowAction가 새로 세팅된 뒤 여기서 openCommentSheet 실행 - openCommentSheet() - } else if (rowAction?.type === "files") { - // Handle files action - const vendorId = rowAction.row.original.vendorId; - const tbeId = rowAction.row.original.tbeId ?? 0; - openFilesDialog(tbeId, vendorId); - } - }, [rowAction]) - - async function openCommentSheet() { - setInitialComments([]) - - const comments = rowAction?.row.original.comments - const vendorId = rowAction?.row.original.vendorId - const tbeId = rowAction?.row.original.tbeId - if (comments && comments.length > 0) { - const commentWithAttachments: TbeComment[] = await Promise.all( - comments.map(async (c) => { - const attachments = await fetchRfqAttachmentsbyCommentId(c.id) - - return { - ...c, - commentedBy: currentUserId, // DB나 API 응답에 있다고 가정 - attachments, - } - }) - ) - // 3) state에 저장 -> CommentSheet에서 initialComments로 사용 - setInitialComments(commentWithAttachments) - } - setSelectedTbeId(tbeId ?? 0) - setSelectedVendorId(vendorId ?? 0) - setCommentSheetOpen(true) - } - - const openFilesDialog = (tbeId: number, vendorId: number) => { - setSelectedTbeId(tbeId) - setSelectedVendorId(vendorId) - setIsFileDialogOpen(true) - } - const openVendorContactsDialog = (vendorId: number, vendor: VendorWithTbeFields) => { - setSelectedVendorId(vendorId) - setSelectedVendor(vendor) - setIsContactDialogOpen(true) - } - - // getColumns() 호출 시, router를 주입 - const columns = React.useMemo( - () => getColumns({ setRowAction, router, openCommentSheet, openFilesDialog, openVendorContactsDialog }), - [setRowAction, router] - ) - - const filterFields: DataTableFilterField<VendorWithTbeFields>[] = [ - ] - - const advancedFilterFields: DataTableAdvancedFilterField<VendorWithTbeFields>[] = [ - { id: "vendorName", label: "Vendor Name", type: "text" }, - { id: "vendorCode", label: "Vendor Code", type: "text" }, - { id: "email", label: "Email", type: "text" }, - { id: "country", label: "Country", type: "text" }, - { - id: "vendorStatus", - label: "Vendor Status", - type: "multi-select", - options: vendors.status.enumValues.map((status) => ({ - label: toSentenceCase(status), - value: status, - })), - }, - { id: "rfqVendorUpdated", label: "Updated at", type: "date" }, - ] - - - const { table } = useDataTable({ - data, - columns, - pageCount, - filterFields, - enablePinning: true, - enableAdvancedFilter: true, - initialState: { - sorting: [{ id: "rfqVendorUpdated", desc: true }], - columnPinning: { right: ["comments"] }, - }, - getRowId: (originalRow) => String(originalRow.id), - shallow: false, - clearOnDefault: true, - }) - - - - return ( - <div style={{ maxWidth: '80vw' }}> - <DataTable - table={table} - > - <DataTableAdvancedToolbar - table={table} - filterFields={advancedFilterFields} - shallow={false} - > - <VendorsTableToolbarActions table={table} rfqId={rfqId} /> - </DataTableAdvancedToolbar> - </DataTable> - <InviteVendorsDialog - vendors={rowAction?.row.original ? [rowAction?.row.original] : []} - onOpenChange={() => setRowAction(null)} - rfqId={rfqId} - open={rowAction?.type === "invite"} - showTrigger={false} - /> - <CommentSheet - currentUserId={currentUserId} - open={commentSheetOpen} - onOpenChange={setCommentSheetOpen} - rfqId={rfqId} - tbeId={selectedTbeId ?? 0} - vendorId={selectedVendorId ?? 0} - initialComments={initialComments} - /> - - <TBEFileDialog - isOpen={isFileDialogOpen} - onOpenChange={setIsFileDialogOpen} - tbeId={selectedTbeId ?? 0} - vendorId={selectedVendorId ?? 0} - rfqId={rfqId} // Use the prop directly instead of data[0]?.rfqId - onRefresh={handleRefresh} - /> - - <TbeResultDialog - open={rowAction?.type === "tbeResult"} - onOpenChange={() => setRowAction(null)} - tbe={rowAction?.row.original ?? null} - /> - - <VendorContactsDialog - isOpen={isContactDialogOpen} - onOpenChange={setIsContactDialogOpen} - vendorId={selectedVendorId} - vendor={selectedVendor} - /> - - </div> - ) -}
\ No newline at end of file diff --git a/lib/rfqs-tech/tbe-table/vendor-contact-dialog.tsx b/lib/rfqs-tech/tbe-table/vendor-contact-dialog.tsx deleted file mode 100644 index 3619fe77..00000000 --- a/lib/rfqs-tech/tbe-table/vendor-contact-dialog.tsx +++ /dev/null @@ -1,71 +0,0 @@ -"use client" - -import * as React from "react" -import { - Dialog, - DialogContent, - DialogHeader, - DialogTitle, -} from "@/components/ui/dialog" -import { VendorContactsTable } from "./vendor-contact/vendor-contact-table" -import { Badge } from "@/components/ui/badge" -import { VendorWithTbeFields } from "@/config/vendorTbeColumnsConfig" - -interface VendorContactsDialogProps { - isOpen: boolean - onOpenChange: (open: boolean) => void - vendorId: number | null - vendor: VendorWithTbeFields | null -} - -export function VendorContactsDialog({ - isOpen, - onOpenChange, - vendorId, - vendor, -}: VendorContactsDialogProps) { - return ( - <Dialog open={isOpen} onOpenChange={onOpenChange}> - <DialogContent className="max-w-[90wv] sm:max-h-[80vh] overflow-auto" style={{maxWidth:1000, height:480}}> - <DialogHeader> - <div className="flex flex-col space-y-2"> - <DialogTitle>협력업체 연락처</DialogTitle> - {vendor && ( - <div className="flex flex-col space-y-1 mt-2"> - <div className="text-sm text-muted-foreground"> - <span className="font-medium text-foreground">{vendor.vendorName}</span> - {vendor.vendorCode && ( - <span className="ml-2 text-xs text-muted-foreground">({vendor.vendorCode})</span> - )} - </div> - <div className="flex items-center"> - {vendor.vendorStatus && ( - <Badge variant="outline" className="mr-2"> - {vendor.vendorStatus} - </Badge> - )} - {vendor.rfqVendorStatus && ( - <Badge - variant={ - vendor.rfqVendorStatus === "INVITED" ? "default" : - vendor.rfqVendorStatus === "DECLINED" ? "destructive" : - vendor.rfqVendorStatus === "ACCEPTED" ? "secondary" : "outline" - } - > - {vendor.rfqVendorStatus} - </Badge> - )} - </div> - </div> - )} - </div> - </DialogHeader> - {vendorId && ( - <div className="py-4"> - <VendorContactsTable vendorId={vendorId} /> - </div> - )} - </DialogContent> - </Dialog> - ) -}
\ No newline at end of file diff --git a/lib/rfqs-tech/tbe-table/vendor-contact/vendor-contact-table-column.tsx b/lib/rfqs-tech/tbe-table/vendor-contact/vendor-contact-table-column.tsx deleted file mode 100644 index fcd0c3fb..00000000 --- a/lib/rfqs-tech/tbe-table/vendor-contact/vendor-contact-table-column.tsx +++ /dev/null @@ -1,70 +0,0 @@ -"use client" -// Because columns rely on React state/hooks for row actions - -import * as React from "react" -import { ColumnDef, Row } from "@tanstack/react-table" -import { ClientDataTableColumnHeaderSimple } from "@/components/client-data-table/data-table-column-simple-header" -import { formatDate } from "@/lib/utils" -import { Checkbox } from "@/components/ui/checkbox" -import { VendorData } from "./vendor-contact-table" - - -/** getColumns: return array of ColumnDef for 'vendors' data */ -export function getColumns(): ColumnDef<VendorData>[] { - return [ - - // Vendor Name - { - accessorKey: "contactName", - header: ({ column }) => ( - <ClientDataTableColumnHeaderSimple column={column} title="Contact Name" /> - ), - cell: ({ row }) => row.getValue("contactName"), - }, - - // Vendor Code - { - accessorKey: "contactPosition", - header: ({ column }) => ( - <ClientDataTableColumnHeaderSimple column={column} title="Position" /> - ), - cell: ({ row }) => row.getValue("contactPosition"), - }, - - // Status - { - accessorKey: "contactEmail", - header: ({ column }) => ( - <ClientDataTableColumnHeaderSimple column={column} title="Email" /> - ), - cell: ({ row }) => row.getValue("contactEmail"), - }, - - // Country - { - accessorKey: "contactPhone", - header: ({ column }) => ( - <ClientDataTableColumnHeaderSimple column={column} title="Phone" /> - ), - cell: ({ row }) => row.getValue("contactPhone"), - }, - - // Created At - { - accessorKey: "createdAt", - header: ({ column }) => ( - <ClientDataTableColumnHeaderSimple column={column} title="Created At" /> - ), - cell: ({ cell }) => formatDate(cell.getValue() as Date), - }, - - // Updated At - { - accessorKey: "updatedAt", - header: ({ column }) => ( - <ClientDataTableColumnHeaderSimple column={column} title="Updated At" /> - ), - cell: ({ cell }) => formatDate(cell.getValue() as Date), - }, - ] -}
\ No newline at end of file diff --git a/lib/rfqs-tech/tbe-table/vendor-contact/vendor-contact-table.tsx b/lib/rfqs-tech/tbe-table/vendor-contact/vendor-contact-table.tsx deleted file mode 100644 index c079da02..00000000 --- a/lib/rfqs-tech/tbe-table/vendor-contact/vendor-contact-table.tsx +++ /dev/null @@ -1,89 +0,0 @@ -'use client' - -import * as React from "react" -import { ClientDataTable } from "@/components/client-data-table/data-table" -import { getColumns } from "./vendor-contact-table-column" -import { DataTableAdvancedFilterField } from "@/types/table" -import { Loader2 } from "lucide-react" -import { useToast } from "@/hooks/use-toast" -import { getVendorContactsByVendorId } from "../../service" - -export interface VendorData { - id: number - contactName: string - contactPosition: string | null - contactEmail: string - contactPhone: string | null - isPrimary: boolean | null - createdAt: Date - updatedAt: Date -} - -interface VendorContactsTableProps { - vendorId: number -} - -export function VendorContactsTable({ vendorId }: VendorContactsTableProps) { - const { toast } = useToast() - - const columns = React.useMemo( - () => getColumns(), - [] - ) - - const [vendorContacts, setVendorContacts] = React.useState<VendorData[]>([]) - const [isLoading, setIsLoading] = React.useState(false) - - React.useEffect(() => { - async function loadVendorContacts() { - setIsLoading(true) - try { - const result = await getVendorContactsByVendorId(vendorId) - if (result.success && result.data) { - // undefined 체크 추가 및 타입 캐스팅 - setVendorContacts(result.data as VendorData[]) - } else { - throw new Error(result.error || "Unknown error occurred") - } - } catch (error) { - console.error("협력업체 연락처 로드 오류:", error) - toast({ - title: "Error", - description: "Failed to load vendor contacts", - variant: "destructive", - }) - } finally { - setIsLoading(false) - } - } - loadVendorContacts() - }, [toast, vendorId]) - - const advancedFilterFields: DataTableAdvancedFilterField<VendorData>[] = [ - { id: "contactName", label: "Contact Name", type: "text" }, - { id: "contactPosition", label: "Posiotion", type: "text" }, - { id: "contactEmail", label: "Email", type: "text" }, - { id: "contactPhone", label: "Phone", type: "text" }, - - - ] - - // If loading, show a flex container that fills the parent and centers the spinner - if (isLoading) { - return ( - <div className="flex h-full w-full items-center justify-center"> - <Loader2 className="h-8 w-8 animate-spin text-muted-foreground" /> - </div> - ) - } - - // Otherwise, show the table - return ( - <ClientDataTable - data={vendorContacts} - columns={columns} - advancedFilterFields={advancedFilterFields} - > - </ClientDataTable> - ) -}
\ No newline at end of file diff --git a/lib/rfqs-tech/validations.ts b/lib/rfqs-tech/validations.ts deleted file mode 100644 index 82b0934e..00000000 --- a/lib/rfqs-tech/validations.ts +++ /dev/null @@ -1,284 +0,0 @@ -import { createSearchParamsCache, - parseAsArrayOf, - parseAsInteger, - parseAsString, - parseAsStringEnum,parseAsBoolean -} from "nuqs/server" -import * as z from "zod" - -import { getFiltersStateParser, getSortingStateParser } from "@/lib/parsers" -import { Rfq, rfqs, RfqsView, VendorResponseCBEView, VendorRfqViewBase, VendorTbeView } from "@/db/schema/rfq"; -import { vendors } from "@/db/schema/vendors"; - -// ======================= -// 1) SearchParams (목록 필터링/정렬) -// ======================= -export const searchParamsCache = createSearchParamsCache({ - // 1) 공통 플래그 - flags: parseAsArrayOf(z.enum(["advancedTable", "floatingBar"])).withDefault([]), - - // 2) 페이지네이션 - page: parseAsInteger.withDefault(1), - perPage: parseAsInteger.withDefault(10), - - // 3) 정렬 (Rfq 테이블) - // getSortingStateParser<Rfq>() → Rfq 테이블의 컬럼명에 맞춘 유효성 검사 - sort: getSortingStateParser<RfqsView>().withDefault([ - { id: "createdAt", desc: true }, - ]), - - // 간단 검색 필드 - rfqCode: parseAsString.withDefault(""), - projectCode: parseAsString.withDefault(""), - projectName: parseAsString.withDefault(""), - dueDate: parseAsString.withDefault(""), - - // 상태 - 여러 개일 수 있다고 가정 - status: parseAsArrayOf(z.enum(rfqs.status.enumValues)).withDefault([]), - - // 고급 필터 - filters: getFiltersStateParser().withDefault([]), - joinOperator: parseAsStringEnum(["and", "or"]).withDefault("and"), - search: parseAsString.withDefault(""), - -}); - -export type GetRfqsSchema = Awaited<ReturnType<typeof searchParamsCache.parse>>; - - -export const searchParamsMatchedVCache = createSearchParamsCache({ - // 1) 공통 플래그 - flags: parseAsArrayOf(z.enum(["advancedTable", "floatingBar"])).withDefault([]), - - // 2) 페이지네이션 - page: parseAsInteger.withDefault(1), - perPage: parseAsInteger.withDefault(10), - - // 3) 정렬 (Rfq 테이블) - // getSortingStateParser<Rfq>() → Rfq 테이블의 컬럼명에 맞춘 유효성 검사 - sort: getSortingStateParser<VendorRfqViewBase>().withDefault([ - { id: "rfqVendorUpdated", desc: true }, - ]), - - // 4) 간단 검색 필드 - vendorName: parseAsString.withDefault(""), - vendorCode: parseAsString.withDefault(""), - country: parseAsString.withDefault(""), - email: parseAsString.withDefault(""), - website: parseAsString.withDefault(""), - - // 5) 상태 (배열) - Rfq["status"]는 "DRAFT"|"PUBLISHED"|"EVALUATION"|"AWARDED" - // rfqs.status.enumValues 로 가져온 문자열 배열을 z.enum([...])로 처리 - vendorStatus: parseAsArrayOf(z.enum(vendors.status.enumValues)).withDefault([]), - - // 6) 고급 필터 (nuqs - filterColumns) - filters: getFiltersStateParser().withDefault([]), - joinOperator: parseAsStringEnum(["and", "or"]).withDefault("and"), - - // 7) 글로벌 검색어 - search: parseAsString.withDefault(""), -}) -export type GetMatchedVendorsSchema = Awaited<ReturnType<typeof searchParamsMatchedVCache.parse>>; - -export const searchParamsTBECache = createSearchParamsCache({ - // 1) 공통 플래그 - flags: parseAsArrayOf(z.enum(["advancedTable", "floatingBar"])).withDefault([]), - - // 2) 페이지네이션 - page: parseAsInteger.withDefault(1), - perPage: parseAsInteger.withDefault(10), - - // 3) 정렬 (Rfq 테이블) - // getSortingStateParser<Rfq>() → Rfq 테이블의 컬럼명에 맞춘 유효성 검사 - sort: getSortingStateParser<VendorTbeView>().withDefault([ - { id: "tbeUpdated", desc: true }, - ]), - - // 4) 간단 검색 필드 - vendorName: parseAsString.withDefault(""), - vendorCode: parseAsString.withDefault(""), - country: parseAsString.withDefault(""), - email: parseAsString.withDefault(""), - website: parseAsString.withDefault(""), - - tbeResult: parseAsString.withDefault(""), - tbeNote: parseAsString.withDefault(""), - tbeUpdated: parseAsString.withDefault(""), - - // 5) 상태 (배열) - Rfq["status"]는 "DRAFT"|"PUBLISHED"|"EVALUATION"|"AWARDED" - // rfqs.status.enumValues 로 가져온 문자열 배열을 z.enum([...])로 처리 - vendorStatus: parseAsArrayOf(z.enum(vendors.status.enumValues)).withDefault([]), - - // 6) 고급 필터 (nuqs - filterColumns) - filters: getFiltersStateParser().withDefault([]), - joinOperator: parseAsStringEnum(["and", "or"]).withDefault("and"), - - // 7) 글로벌 검색어 - search: parseAsString.withDefault(""), -}) -export type GetTBESchema = Awaited<ReturnType<typeof searchParamsTBECache.parse>>; - -// ======================= -// 2) Create RFQ Schema -// ======================= -export const createRfqSchema = z.object({ - rfqCode: z.string().min(3, "RFQ 코드는 최소 3글자 이상이어야 합니다"), - description: z.string().optional(), - projectId: z.number().nullable().optional(), // 프로젝트 ID (선택적) - bidProjectId: z.number().nullable().optional(), // 프로젝트 ID (선택적) - dueDate: z.date(), - status: z.enum(["DRAFT", "PUBLISHED", "EVALUATION", "AWARDED"]), - createdBy: z.number(), -}); - -export type CreateRfqSchema = z.infer<typeof createRfqSchema>; - -export const createRfqItemSchema = z.object({ - rfqId: z.number().int().min(1, "Invalid RFQ ID"), - itemCode: z.string().min(1), - itemName: z.string().optional(), - description: z.string().optional(), - quantity: z.number().min(1).optional(), - uom: z.string().optional(), -}); - -export type CreateRfqItemSchema = z.infer<typeof createRfqItemSchema>; - -// ======================= -// 3) Update RFQ Schema -// (현재 코드엔 updateTaskSchema라고 되어 있는데, -// RFQ 업데이트이므로 'updateRfqSchema'라 명명하는 게 자연스러움) -// ======================= -export const updateRfqSchema = z.object({ - // PK id -> 실제로는 URL params로 받을 수도 있지만, - // 여기서는 body에서 받는다고 가정 - id: z.number().int().min(1, "Invalid ID"), - - // 업데이트 시 대부분 optional - rfqCode: z.string().max(50).optional(), - projectId: z.number().nullable().optional(), // null 값도 허용 - description: z.string().optional(), - dueDate: z.preprocess( - // null이나 빈 문자열을 undefined로 변환 - (val) => (val === null || val === '') ? undefined : val, - z.date().optional() - ), - status: z.union([ - z.enum(["DRAFT", "PUBLISHED", "EVALUATION", "AWARDED"]), - z.string().refine( - (val) => ["DRAFT", "PUBLISHED", "EVALUATION", "AWARDED"].includes(val), - { message: "Invalid status value" } - ) - ]).optional(), - createdBy: z.number().int().min(1).optional(), -}); -export type UpdateRfqSchema = z.infer<typeof updateRfqSchema>; - -export const searchParamsRfqsForVendorsCache = createSearchParamsCache({ - // 1) 공통 플래그 - flags: parseAsArrayOf(z.enum(["advancedTable", "floatingBar"])).withDefault([]), - - // 2) 페이지네이션 - page: parseAsInteger.withDefault(1), - perPage: parseAsInteger.withDefault(10), - - // 3) 정렬 (rfqs 테이블) - sort: getSortingStateParser<Rfq>().withDefault([ - { id: "createdAt", desc: true }, - ]), - - // 4) 간단 검색 필드 (예: rfqCode, projectName, projectCode 등) - rfqCode: parseAsString.withDefault(""), - projectCode: parseAsString.withDefault(""), - projectName: parseAsString.withDefault(""), - - // 5) 상태 배열 (rfqs.status.enumValues: "DRAFT" | "PUBLISHED" | ...) - status: parseAsArrayOf(z.enum(rfqs.status.enumValues)).withDefault([]), - - // 6) 고급 필터 (nuqs filterColumns) - filters: getFiltersStateParser().withDefault([]), - joinOperator: parseAsStringEnum(["and", "or"]).withDefault("and"), - - // 7) 글로벌 검색어 - search: parseAsString.withDefault(""), -}) - -/** - * 최종 타입 - * `Awaited<ReturnType<...parse>>` 형태로 - * Next.js 13 서버 액션이나 클라이언트에서 사용 가능 - */ -export type GetRfqsForVendorsSchema = Awaited<ReturnType<typeof searchParamsRfqsForVendorsCache.parse>> - -export const updateRfqVendorSchema = z.object({ - id: z.number().int().min(1, "Invalid ID"), // rfq_vendors.id - status: z.enum(["INVITED","ACCEPTED","DECLINED","REVIEWING", "RESPONDED"]) -}) - -export type UpdateRfqVendorSchema = z.infer<typeof updateRfqVendorSchema> - - -export const searchParamsCBECache = createSearchParamsCache({ - // 1) 공통 플래그 - flags: parseAsArrayOf(z.enum(["advancedTable", "floatingBar"])).withDefault([]), - - // 2) 페이지네이션 - page: parseAsInteger.withDefault(1), - perPage: parseAsInteger.withDefault(10), - - // 3) 정렬 (VendorResponseCBEView 테이블) - // getSortingStateParser<VendorResponseCBEView>() → CBE 테이블의 컬럼명에 맞춤 - sort: getSortingStateParser<VendorResponseCBEView>().withDefault([ - { id: "totalPrice", desc: true }, - ]), - - // 4) 간단 검색 필드 - 기본 정보 - vendorName: parseAsString.withDefault(""), - vendorCode: parseAsString.withDefault(""), - country: parseAsString.withDefault(""), - email: parseAsString.withDefault(""), - website: parseAsString.withDefault(""), - - // CBE 관련 필드 - commercialResponseId: parseAsString.withDefault(""), - totalPrice: parseAsString.withDefault(""), - currency: parseAsString.withDefault(""), - paymentTerms: parseAsString.withDefault(""), - incoterms: parseAsString.withDefault(""), - deliveryPeriod: parseAsString.withDefault(""), - warrantyPeriod: parseAsString.withDefault(""), - validityPeriod: parseAsString.withDefault(""), - - // 응답 상태 - responseStatus: parseAsStringEnum(["INVITED", "ACCEPTED", "DECLINED", "REVIEWING", "RESPONDED"]).withDefault("REVIEWING"), - - // 5) 상태 (배열) - vendor 상태 - vendorStatus: parseAsArrayOf(z.enum(vendors.status.enumValues)).withDefault([]), - - // 6) 고급 필터 (nuqs - filterColumns) - filters: getFiltersStateParser().withDefault([]), - joinOperator: parseAsStringEnum(["and", "or"]).withDefault("and"), - - // 7) 글로벌 검색어 - search: parseAsString.withDefault(""), - - // 8) 첨부파일 관련 필터 - hasAttachments: parseAsBoolean.withDefault(false), - - // 9) 날짜 범위 필터 - respondedAtRange: parseAsString.withDefault(""), - commercialUpdatedAtRange: parseAsString.withDefault(""), -}) - -export type GetCBESchema = Awaited<ReturnType<typeof searchParamsCBECache.parse>>; - - -export const createCbeEvaluationSchema = z.object({ - paymentTerms: z.string().min(1, "결제 조건을 입력하세요"), - incoterms: z.string().min(1, "Incoterms를 입력하세요"), - deliverySchedule: z.string().min(1, "배송 일정을 입력하세요"), - notes: z.string().optional(), -}) - -// 타입 추출 -export type CreateCbeEvaluationSchema = z.infer<typeof createCbeEvaluationSchema>
\ No newline at end of file diff --git a/lib/rfqs-tech/vendor-table/add-vendor-dialog.tsx b/lib/rfqs-tech/vendor-table/add-vendor-dialog.tsx deleted file mode 100644 index 8ec5b9f4..00000000 --- a/lib/rfqs-tech/vendor-table/add-vendor-dialog.tsx +++ /dev/null @@ -1,37 +0,0 @@ -"use client" - -import * as React from "react" -import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog" -import { Button } from "@/components/ui/button" -import { VendorsListTable } from "./vendor-list/vendor-list-table" - -interface VendorsListTableProps { - rfqId: number // so we know which RFQ to insert into - } - - -/** - * A dialog that contains a client-side table or infinite scroll - * for "all vendors," allowing the user to select vendors and add them to the RFQ. - */ -export function AddVendorDialog({ rfqId }: VendorsListTableProps) { - const [open, setOpen] = React.useState(false) - - return ( - <Dialog open={open} onOpenChange={setOpen}> - <DialogTrigger asChild> - <Button size="sm"> - Add Vendor - </Button> - </DialogTrigger> - <DialogContent className="max-w-[90wv] sm:max-h-[80vh] overflow-auto" style={{maxWidth:1600, height:680}}> - <DialogHeader> - <DialogTitle>Add Vendor to RFQ</DialogTitle> - </DialogHeader> - - <VendorsListTable rfqId={rfqId}/> - - </DialogContent> - </Dialog> - ) -}
\ No newline at end of file diff --git a/lib/rfqs-tech/vendor-table/comments-sheet.tsx b/lib/rfqs-tech/vendor-table/comments-sheet.tsx deleted file mode 100644 index 441fdcf1..00000000 --- a/lib/rfqs-tech/vendor-table/comments-sheet.tsx +++ /dev/null @@ -1,318 +0,0 @@ -"use client" - -import * as React from "react" -import { useForm, useFieldArray } from "react-hook-form" -import { z } from "zod" -import { zodResolver } from "@hookform/resolvers/zod" -import { Download, X, Loader2 } from "lucide-react" -import prettyBytes from "pretty-bytes" -import { toast } from "sonner" - -import { - Sheet, - SheetClose, - SheetContent, - SheetDescription, - SheetFooter, - SheetHeader, - SheetTitle, -} from "@/components/ui/sheet" -import { Button } from "@/components/ui/button" -import { - Form, - FormControl, - FormField, - FormItem, - FormLabel, - FormMessage, -} from "@/components/ui/form" -import { Textarea } from "@/components/ui/textarea" -import { - Dropzone, - DropzoneZone, - DropzoneUploadIcon, - DropzoneTitle, - DropzoneDescription, - DropzoneInput, -} from "@/components/ui/dropzone" -import { - Table, - TableHeader, - TableRow, - TableHead, - TableBody, - TableCell, -} from "@/components/ui/table" - -import { createRfqCommentWithAttachments } from "../service" -import { formatDate } from "@/lib/utils" - - -export interface MatchedVendorComment { - id: number - commentText: string - commentedBy?: number - commentedByEmail?: string - createdAt?: Date - attachments?: { - id: number - fileName: string - filePath: string - }[] -} - -// 1) props 정의 -interface CommentSheetProps extends React.ComponentPropsWithRef<typeof Sheet> { - initialComments?: MatchedVendorComment[] - currentUserId: number - rfqId: number - vendorId: number - onCommentsUpdated?: (comments: MatchedVendorComment[]) => void - isLoading?: boolean // New prop -} - -// 2) 폼 스키마 -const commentFormSchema = z.object({ - commentText: z.string().min(1, "댓글을 입력하세요."), - newFiles: z.array(z.any()).optional(), // File[] -}) -type CommentFormValues = z.infer<typeof commentFormSchema> - -const MAX_FILE_SIZE = 30e6 // 30MB - -export function CommentSheet({ - rfqId, - vendorId, - initialComments = [], - currentUserId, - onCommentsUpdated, - isLoading = false, // Default to false - ...props -}: CommentSheetProps) { - - const [comments, setComments] = React.useState<MatchedVendorComment[]>(initialComments) - const [isPending, startTransition] = React.useTransition() - - React.useEffect(() => { - setComments(initialComments) - }, [initialComments]) - - const form = useForm<CommentFormValues>({ - resolver: zodResolver(commentFormSchema), - defaultValues: { - commentText: "", - newFiles: [], - }, - }) - - const { fields: newFileFields, append, remove } = useFieldArray({ - control: form.control, - name: "newFiles", - }) - - // (A) 기존 코멘트 렌더링 - function renderExistingComments() { - - if (isLoading) { - return ( - <div className="flex justify-center items-center h-32"> - <Loader2 className="h-4 w-4 animate-spin text-muted-foreground" /> - <span className="ml-2 text-sm text-muted-foreground">Loading comments...</span> - </div> - ) - } - - if (comments.length === 0) { - return <p className="text-sm text-muted-foreground">No comments yet</p> - } - return ( - <Table> - <TableHeader> - <TableRow> - <TableHead className="w-1/2">Comment</TableHead> - <TableHead>Attachments</TableHead> - <TableHead>Created At</TableHead> - <TableHead>Created By</TableHead> - </TableRow> - </TableHeader> - <TableBody> - {comments.map((c) => ( - <TableRow key={c.id}> - <TableCell>{c.commentText}</TableCell> - <TableCell> - {!c.attachments?.length && ( - <span className="text-sm text-muted-foreground">No files</span> - )} - {c.attachments?.length && ( - <div className="flex flex-col gap-1"> - {c.attachments.map((att) => ( - <div key={att.id} className="flex items-center gap-2"> - <a - href={`/api/rfq-download?path=${encodeURIComponent(att.filePath)}`} - download - target="_blank" - rel="noreferrer" - className="inline-flex items-center gap-1 text-blue-600 underline" - > - <Download className="h-4 w-4" /> - {att.fileName} - </a> - </div> - ))} - </div> - )} - </TableCell> - <TableCell> {c.createdAt ? formatDate(c.createdAt) : "-"}</TableCell> - <TableCell>{c.commentedByEmail ?? "-"}</TableCell> - </TableRow> - ))} - </TableBody> - </Table> - ) - } - - // (B) 파일 드롭 - function handleDropAccepted(files: File[]) { - append(files) - } - - // (C) Submit - async function onSubmit(data: CommentFormValues) { - if (!rfqId) return - startTransition(async () => { - try { - const res = await createRfqCommentWithAttachments({ - rfqId, - vendorId, - commentText: data.commentText, - commentedBy: currentUserId, - evaluationId: null, - cbeId: null, - files: data.newFiles, - }) - - if (!res.ok) { - throw new Error("Failed to create comment") - } - - toast.success("Comment created") - - // 임시로 새 코멘트 추가 - const newComment: MatchedVendorComment = { - id: res.commentId, // 서버 응답 - commentText: data.commentText, - commentedBy: currentUserId, - createdAt: res.createdAt, - attachments: - data.newFiles?.map((f) => ({ - id: Math.floor(Math.random() * 1e6), - fileName: f.name, - filePath: "/uploads/" + f.name, - })) || [], - } - setComments((prev) => [...prev, newComment]) - onCommentsUpdated?.([...comments, newComment]) - - form.reset() - } catch (err: any) { - console.error(err) - toast.error("Error: " + err.message) - } - }) - } - - return ( - <Sheet {...props}> - <SheetContent className="flex flex-col gap-6 sm:max-w-lg"> - <SheetHeader className="text-left"> - <SheetTitle>Comments</SheetTitle> - <SheetDescription> - 필요시 첨부파일과 함께 문의/코멘트를 남길 수 있습니다. - </SheetDescription> - </SheetHeader> - - <div className="max-h-[300px] overflow-y-auto">{renderExistingComments()}</div> - - <Form {...form}> - <form onSubmit={form.handleSubmit(onSubmit)} className="flex flex-col gap-4"> - <FormField - control={form.control} - name="commentText" - render={({ field }) => ( - <FormItem> - <FormLabel>New Comment</FormLabel> - <FormControl> - <Textarea placeholder="Enter your comment..." {...field} /> - </FormControl> - <FormMessage /> - </FormItem> - )} - /> - - <Dropzone - maxSize={MAX_FILE_SIZE} - onDropAccepted={handleDropAccepted} - onDropRejected={(rej) => { - toast.error("File rejected: " + (rej[0]?.file?.name || "")) - }} - > - {({ maxSize }) => ( - <DropzoneZone className="flex justify-center"> - <DropzoneInput /> - <div className="flex items-center gap-6"> - <DropzoneUploadIcon /> - <div className="grid gap-0.5"> - <DropzoneTitle>Drop to attach files</DropzoneTitle> - <DropzoneDescription> - Max size: {prettyBytes(maxSize || 0)} - </DropzoneDescription> - </div> - </div> - </DropzoneZone> - )} - </Dropzone> - - {newFileFields.length > 0 && ( - <div className="flex flex-col gap-2"> - {newFileFields.map((field, idx) => { - const file = form.getValues(`newFiles.${idx}`) - if (!file) return null - return ( - <div - key={field.id} - className="flex items-center justify-between border rounded p-2" - > - <span className="text-sm"> - {file.name} ({prettyBytes(file.size)}) - </span> - <Button - variant="ghost" - size="icon" - type="button" - onClick={() => remove(idx)} - > - <X className="h-4 w-4" /> - </Button> - </div> - ) - })} - </div> - )} - - <SheetFooter className="gap-2 pt-4"> - <SheetClose asChild> - <Button type="button" variant="outline"> - Cancel - </Button> - </SheetClose> - <Button disabled={isPending}> - {isPending && <Loader2 className="mr-2 h-4 w-4 animate-spin" />} - Save - </Button> - </SheetFooter> - </form> - </Form> - </SheetContent> - </Sheet> - ) -}
\ No newline at end of file diff --git a/lib/rfqs-tech/vendor-table/feature-flags-provider.tsx b/lib/rfqs-tech/vendor-table/feature-flags-provider.tsx deleted file mode 100644 index 81131894..00000000 --- a/lib/rfqs-tech/vendor-table/feature-flags-provider.tsx +++ /dev/null @@ -1,108 +0,0 @@ -"use client" - -import * as React from "react" -import { useQueryState } from "nuqs" - -import { dataTableConfig, type DataTableConfig } from "@/config/data-table" -import { cn } from "@/lib/utils" -import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group" -import { - Tooltip, - TooltipContent, - TooltipTrigger, -} from "@/components/ui/tooltip" - -type FeatureFlagValue = DataTableConfig["featureFlags"][number]["value"] - -interface FeatureFlagsContextProps { - featureFlags: FeatureFlagValue[] - setFeatureFlags: (value: FeatureFlagValue[]) => void -} - -const FeatureFlagsContext = React.createContext<FeatureFlagsContextProps>({ - featureFlags: [], - setFeatureFlags: () => {}, -}) - -export function useFeatureFlags() { - const context = React.useContext(FeatureFlagsContext) - if (!context) { - throw new Error( - "useFeatureFlags must be used within a FeatureFlagsProvider" - ) - } - return context -} - -interface FeatureFlagsProviderProps { - children: React.ReactNode -} - -export function FeatureFlagsProvider({ children }: FeatureFlagsProviderProps) { - const [featureFlags, setFeatureFlags] = useQueryState<FeatureFlagValue[]>( - "flags", - { - defaultValue: [], - parse: (value) => value.split(",") as FeatureFlagValue[], - serialize: (value) => value.join(","), - eq: (a, b) => - a.length === b.length && a.every((value, index) => value === b[index]), - clearOnDefault: true, - shallow: false, - } - ) - - return ( - <FeatureFlagsContext.Provider - value={{ - featureFlags, - setFeatureFlags: (value) => void setFeatureFlags(value), - }} - > - <div className="w-full overflow-x-auto"> - <ToggleGroup - type="multiple" - variant="outline" - size="sm" - value={featureFlags} - onValueChange={(value: FeatureFlagValue[]) => setFeatureFlags(value)} - className="w-fit gap-0" - > - {dataTableConfig.featureFlags.map((flag, index) => ( - <Tooltip key={flag.value}> - <ToggleGroupItem - value={flag.value} - className={cn( - "gap-2 whitespace-nowrap rounded-none px-3 text-xs data-[state=on]:bg-accent/70 data-[state=on]:hover:bg-accent/90", - { - "rounded-l-sm border-r-0": index === 0, - "rounded-r-sm": - index === dataTableConfig.featureFlags.length - 1, - } - )} - asChild - > - <TooltipTrigger> - <flag.icon className="size-3.5 shrink-0" aria-hidden="true" /> - {flag.label} - </TooltipTrigger> - </ToggleGroupItem> - <TooltipContent - align="start" - side="bottom" - sideOffset={6} - className="flex max-w-60 flex-col space-y-1.5 border bg-background py-2 font-semibold text-foreground" - > - <div>{flag.tooltipTitle}</div> - <div className="text-xs text-muted-foreground"> - {flag.tooltipDescription} - </div> - </TooltipContent> - </Tooltip> - ))} - </ToggleGroup> - </div> - {children} - </FeatureFlagsContext.Provider> - ) -} diff --git a/lib/rfqs-tech/vendor-table/invite-vendors-dialog.tsx b/lib/rfqs-tech/vendor-table/invite-vendors-dialog.tsx deleted file mode 100644 index 8238e7b9..00000000 --- a/lib/rfqs-tech/vendor-table/invite-vendors-dialog.tsx +++ /dev/null @@ -1,173 +0,0 @@ -"use client" - -import * as React from "react" -import { type Row } from "@tanstack/react-table" -import { Loader, Send, Trash, AlertTriangle } 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 { Alert, AlertDescription } from "@/components/ui/alert" - -import { MatchedVendorRow } from "@/config/vendorRfbColumnsConfig" -import { inviteVendors } from "../service" - -interface DeleteTasksDialogProps - extends React.ComponentPropsWithoutRef<typeof Dialog> { - vendors: Row<MatchedVendorRow>["original"][] - rfqId:number - showTrigger?: boolean - onSuccess?: () => void -} - -export function InviteVendorsDialog({ - vendors, - rfqId, - showTrigger = true, - onSuccess, - ...props -}: DeleteTasksDialogProps) { - const [isInvitePending, startInviteTransition] = React.useTransition() - const isDesktop = useMediaQuery("(min-width: 640px)") - - function onDelete() { - startInviteTransition(async () => { - const { error } = await inviteVendors({ - rfqId, - vendorIds: vendors.map((vendor) => Number(vendor.id)), - }) - - if (error) { - toast.error(error) - return - } - - props.onOpenChange?.(false) - toast.success("Vendor invited") - onSuccess?.() - }) - } - - if (isDesktop) { - return ( - <Dialog {...props}> - {showTrigger ? ( - <DialogTrigger asChild> - <Button variant="outline" size="sm"> - <Send className="mr-2 size-4" aria-hidden="true" /> - Invite ({vendors.length}) - </Button> - </DialogTrigger> - ) : null} - <DialogContent> - <DialogHeader> - <DialogTitle>Are you absolutely sure?</DialogTitle> - <DialogDescription> - This action cannot be undone. This will permanently invite{" "} - <span className="font-medium">{vendors.length}</span> - {vendors.length === 1 ? " vendor" : " vendors"}. - </DialogDescription> - </DialogHeader> - - {/* 편집 제한 경고 메시지 */} - <Alert variant="destructive" className="mt-4"> - <AlertTriangle className="h-4 w-4" /> - <AlertDescription className="font-medium"> - 한 업체라도 초대를 하고 나면 아이템 편집과 RFQ 문서 첨부 편집은 불가능합니다. - </AlertDescription> - </Alert> - - <DialogFooter className="gap-2 sm:space-x-0 mt-6"> - <DialogClose asChild> - <Button variant="outline">Cancel</Button> - </DialogClose> - <Button - aria-label="Invite selected rows" - variant="destructive" - onClick={onDelete} - disabled={isInvitePending} - > - {isInvitePending && ( - <Loader - className="mr-2 size-4 animate-spin" - aria-hidden="true" - /> - )} - Invite - </Button> - </DialogFooter> - </DialogContent> - </Dialog> - ) - } - - return ( - <Drawer {...props}> - {showTrigger ? ( - <DrawerTrigger asChild> - <Button variant="outline" size="sm"> - <Trash className="mr-2 size-4" aria-hidden="true" /> - Invite ({vendors.length}) - </Button> - </DrawerTrigger> - ) : null} - <DrawerContent> - <DrawerHeader> - <DrawerTitle>Are you absolutely sure?</DrawerTitle> - <DrawerDescription> - This action cannot be undone. This will permanently invite {" "} - <span className="font-medium">{vendors.length}</span> - {vendors.length === 1 ? " vendor" : " vendors"} from our servers. - </DrawerDescription> - </DrawerHeader> - - {/* 편집 제한 경고 메시지 (모바일용) */} - <div className="px-4"> - <Alert variant="destructive"> - <AlertTriangle className="h-4 w-4" /> - <AlertDescription className="font-medium"> - 한 업체라도 초대를 하고 나면 아이템 편집과 RFQ 문서 첨부 편집은 불가능합니다. - </AlertDescription> - </Alert> - </div> - - <DrawerFooter className="gap-2 sm:space-x-0 mt-4"> - <DrawerClose asChild> - <Button variant="outline">Cancel</Button> - </DrawerClose> - <Button - aria-label="Delete selected rows" - variant="destructive" - onClick={onDelete} - disabled={isInvitePending} - > - {isInvitePending && ( - <Loader className="mr-2 size-4 animate-spin" aria-hidden="true" /> - )} - Invite - </Button> - </DrawerFooter> - </DrawerContent> - </Drawer> - ) -}
\ No newline at end of file diff --git a/lib/rfqs-tech/vendor-table/vendor-list/vendor-list-table-column.tsx b/lib/rfqs-tech/vendor-table/vendor-list/vendor-list-table-column.tsx deleted file mode 100644 index f9c5d9df..00000000 --- a/lib/rfqs-tech/vendor-table/vendor-list/vendor-list-table-column.tsx +++ /dev/null @@ -1,152 +0,0 @@ -"use client" -// Because columns rely on React state/hooks for row actions - -import * as React from "react" -import { ColumnDef, Row } from "@tanstack/react-table" -import { VendorData } from "./vendor-list-table" -import { ClientDataTableColumnHeaderSimple } from "@/components/client-data-table/data-table-column-simple-header" -import { formatDate } from "@/lib/utils" -import { Checkbox } from "@/components/ui/checkbox" - -export interface DataTableRowAction<TData> { - row: Row<TData> - type: "open" | "update" | "delete" -} - -interface GetColumnsProps { - setSelectedVendorIds: React.Dispatch<React.SetStateAction<number[]>> // Changed to array -} - -/** getColumns: return array of ColumnDef for 'vendors' data */ -export function getColumns({ - setSelectedVendorIds, // Changed parameter name -}: GetColumnsProps): ColumnDef<VendorData>[] { - return [ - // MULTIPLE SELECT COLUMN - { - id: "select", - enableSorting: false, - enableHiding: false, - size: 40, - // Add checkbox in header for select all functionality - header: ({ table }) => ( - <Checkbox - checked={ - table.getFilteredSelectedRowModel().rows.length > 0 && - table.getFilteredSelectedRowModel().rows.length === table.getFilteredRowModel().rows.length - } - onCheckedChange={(checked) => { - table.toggleAllRowsSelected(!!checked) - - // Update selectedVendorIds based on all rows selection - if (checked) { - const allIds = table.getFilteredRowModel().rows.map(row => row.original.id) - setSelectedVendorIds(allIds) - } else { - setSelectedVendorIds([]) - } - }} - aria-label="Select all" - /> - ), - cell: ({ row }) => { - const isSelected = row.getIsSelected() - - return ( - <Checkbox - checked={isSelected} - onCheckedChange={(checked) => { - row.toggleSelected(!!checked) - - // Update the selectedVendorIds state by adding or removing this ID - setSelectedVendorIds(prevIds => { - if (checked) { - // Add this ID if it doesn't exist - return prevIds.includes(row.original.id) - ? prevIds - : [...prevIds, row.original.id] - } else { - // Remove this ID - return prevIds.filter(id => id !== row.original.id) - } - }) - }} - aria-label="Select row" - /> - ) - }, - }, - - // Vendor Name - { - accessorKey: "vendorName", - header: ({ column }) => ( - <ClientDataTableColumnHeaderSimple column={column} title="Vendor Name" /> - ), - cell: ({ row }) => row.getValue("vendorName"), - }, - - // Vendor Code - { - accessorKey: "vendorCode", - header: ({ column }) => ( - <ClientDataTableColumnHeaderSimple column={column} title="Vendor Code" /> - ), - cell: ({ row }) => row.getValue("vendorCode"), - }, - - // Status - { - accessorKey: "status", - header: ({ column }) => ( - <ClientDataTableColumnHeaderSimple column={column} title="Status" /> - ), - cell: ({ row }) => row.getValue("status"), - }, - - // Country - { - accessorKey: "country", - header: ({ column }) => ( - <ClientDataTableColumnHeaderSimple column={column} title="Country" /> - ), - cell: ({ row }) => row.getValue("country"), - }, - - // Email - { - accessorKey: "email", - header: ({ column }) => ( - <ClientDataTableColumnHeaderSimple column={column} title="Email" /> - ), - cell: ({ row }) => row.getValue("email"), - }, - - // Phone - { - accessorKey: "phone", - header: ({ column }) => ( - <ClientDataTableColumnHeaderSimple column={column} title="Phone" /> - ), - cell: ({ row }) => row.getValue("phone"), - }, - - // Created At - { - accessorKey: "createdAt", - header: ({ column }) => ( - <ClientDataTableColumnHeaderSimple column={column} title="Created At" /> - ), - cell: ({ cell }) => formatDate(cell.getValue() as Date), - }, - - // Updated At - { - accessorKey: "updatedAt", - header: ({ column }) => ( - <ClientDataTableColumnHeaderSimple column={column} title="Updated At" /> - ), - cell: ({ cell }) => formatDate(cell.getValue() as Date), - }, - ] -}
\ No newline at end of file diff --git a/lib/rfqs-tech/vendor-table/vendor-list/vendor-list-table.tsx b/lib/rfqs-tech/vendor-table/vendor-list/vendor-list-table.tsx deleted file mode 100644 index defbac86..00000000 --- a/lib/rfqs-tech/vendor-table/vendor-list/vendor-list-table.tsx +++ /dev/null @@ -1,176 +0,0 @@ -"use client" - -import * as React from "react" -import { ClientDataTable } from "@/components/client-data-table/data-table" -import { getColumns } from "./vendor-list-table-column" -import { DataTableAdvancedFilterField } from "@/types/table" -import { addItemToVendors, getAllVendors } from "../../service" -import { Loader2, Plus } from "lucide-react" -import { Button } from "@/components/ui/button" -import { useToast } from "@/hooks/use-toast" - -export interface VendorData { - id: number - vendorName: string - vendorCode: string | null - taxId: string - address: string | null - country: string | null - phone: string | null - email: string | null - website: string | null - status: string - createdAt: Date - updatedAt: Date -} - -interface VendorsListTableProps { - rfqId: number -} - -export function VendorsListTable({ rfqId }: VendorsListTableProps) { - const { toast } = useToast() - - // Changed to array for multiple selection - const [selectedVendorIds, setSelectedVendorIds] = React.useState<number[]>([]) - const [isSubmitting, setIsSubmitting] = React.useState(false) - - const [vendors, setVendors] = React.useState<VendorData[]>([]) - const [isLoading, setIsLoading] = React.useState(false) - - const columns = React.useMemo( - () => getColumns({ setSelectedVendorIds }), - [setSelectedVendorIds] - ) - - // 고급 필터 필드 정의 - const advancedFilterFields: DataTableAdvancedFilterField<VendorData>[] = [ - { - id: "vendorName", - label: "Vendor Name", - type: "text", - }, - { - id: "vendorCode", - label: "Vendor Code", - type: "text", - }, - { - id: "status", - label: "Status", - type: "select", - options: [ - { label: "Active", value: "ACTIVE" }, - { label: "Inactive", value: "INACTIVE" }, - { label: "Pending", value: "PENDING" }, - ], - }, - { - id: "country", - label: "Country", - type: "text", - }, - { - id: "email", - label: "Email", - type: "text", - }, - ] - - // 초기 데이터 로드 - React.useEffect(() => { - async function loadVendors() { - setIsLoading(true) - try { - const result = await getAllVendors() - - if (result.data) { - setVendors(result.data) - } - } catch (error) { - console.error("협력업체 목록 로드 오류:", error) - toast({ - title: "Error", - description: "Failed to load vendors", - variant: "destructive", - }) - } finally { - setIsLoading(false) - } - } - - loadVendors() - }, [toast]) - - async function handleAddVendors() { - if (selectedVendorIds.length === 0) return // Safety check - - setIsSubmitting(true) - try { - // Update to use the multiple vendor service - const result = await addItemToVendors(rfqId, selectedVendorIds) - - if (result.success) { - toast({ - title: "Success", - description: `Added items to ${selectedVendorIds.length} vendors`, - }) - // Reset selection after successful addition - setSelectedVendorIds([]) - } else { - toast({ - title: "Error", - description: result.error || "Failed to add items to vendors", - variant: "destructive", - }) - } - } catch (err) { - console.error("Failed to add vendors:", err) - toast({ - title: "Error", - description: "An unexpected error occurred", - variant: "destructive", - }) - } finally { - setIsSubmitting(false) - } - } - - // If loading, show a flex container that fills the parent and centers the spinner - if (isLoading && vendors.length === 0) { - return ( - <div className="flex h-full w-full items-center justify-center"> - <Loader2 className="h-8 w-8 animate-spin text-muted-foreground" /> - </div> - ) - } - - return ( - <ClientDataTable - data={vendors} - columns={columns} - advancedFilterFields={advancedFilterFields} - > - <div className="flex items-center gap-2"> - <Button - variant="default" - size="sm" - onClick={handleAddVendors} - disabled={selectedVendorIds.length === 0 || isSubmitting} - > - {isSubmitting ? ( - <> - <Loader2 className="mr-2 h-4 w-4 animate-spin" /> - Adding... - </> - ) : ( - <> - <Plus className="mr-2 h-4 w-4" /> - Add Vendors ({selectedVendorIds.length}) - </> - )} - </Button> - </div> - </ClientDataTable> - ) -}
\ No newline at end of file diff --git a/lib/rfqs-tech/vendor-table/vendors-table-columns.tsx b/lib/rfqs-tech/vendor-table/vendors-table-columns.tsx deleted file mode 100644 index 5354f93a..00000000 --- a/lib/rfqs-tech/vendor-table/vendors-table-columns.tsx +++ /dev/null @@ -1,219 +0,0 @@ -"use client" - -import * as React from "react" -import { type DataTableRowAction } from "@/types/table" -import { type ColumnDef } from "@tanstack/react-table" -import { Ellipsis, MessageSquare } from "lucide-react" -import { toast } from "sonner" - -import { formatDate } from "@/lib/utils" -import { Badge } from "@/components/ui/badge" -import { Button } from "@/components/ui/button" -import { Checkbox } from "@/components/ui/checkbox" - -import { useRouter } from "next/navigation" - -import { DataTableColumnHeaderSimple } from "@/components/data-table/data-table-column-simple-header" -import { MatchedVendorRow, vendorRfqColumnsConfig } from "@/config/vendorRfbColumnsConfig" - - -type NextRouter = ReturnType<typeof useRouter>; - - -interface GetColumnsProps { - setRowAction: React.Dispatch<React.SetStateAction<DataTableRowAction<MatchedVendorRow> | null>>; - router: NextRouter; - openCommentSheet: (rfqId: number) => void; - -} - -/** - * tanstack table 컬럼 정의 (중첩 헤더 버전) - */ -export function getColumns({ setRowAction, router, openCommentSheet }: GetColumnsProps): ColumnDef<MatchedVendorRow>[] { - // ---------------------------------------------------------------- - // 1) select 컬럼 (체크박스) - // ---------------------------------------------------------------- - const selectColumn: ColumnDef<MatchedVendorRow> = { - id: "select", - header: ({ table }) => ( - <Checkbox - checked={ - table.getIsAllPageRowsSelected() || - (table.getIsSomePageRowsSelected() && "indeterminate") - } - onCheckedChange={(value) => table.toggleAllPageRowsSelected(!!value)} - aria-label="Select all" - className="translate-y-0.5" - /> - ), - cell: ({ row }) => ( - <Checkbox - checked={row.getIsSelected()} - onCheckedChange={(value) => row.toggleSelected(!!value)} - aria-label="Select row" - className="translate-y-0.5" - /> - ), - size: 40, - enableSorting: false, - enableHiding: false, - } - - // ---------------------------------------------------------------- - // 3) 일반 컬럼들을 "그룹"별로 묶어 중첩 columns 생성 - // ---------------------------------------------------------------- - // 3-1) groupMap: { [groupName]: ColumnDef<MatchedVendorRow>[] } - const groupMap: Record<string, ColumnDef<MatchedVendorRow>[]> = {} - - vendorRfqColumnsConfig.forEach((cfg) => { - // 만약 group가 없으면 "_noGroup" 처리 - const groupName = cfg.group || "_noGroup" - - if (!groupMap[groupName]) { - groupMap[groupName] = [] - } - - // child column 정의 - const childCol: ColumnDef<MatchedVendorRow> = { - accessorKey: cfg.id, - enableResizing: true, - header: ({ column }) => ( - <DataTableColumnHeaderSimple column={column} title={cfg.label} /> - ), - meta: { - excelHeader: cfg.excelHeader, - group: cfg.group, - type: cfg.type, - }, - cell: ({ row, cell }) => { - - - if (cfg.id === "vendorStatus") { - const statusVal = row.original.vendorStatus - if (!statusVal) return null - // const Icon = getStatusIcon(statusVal) - return ( - <Badge variant="outline"> - {statusVal} - </Badge> - ) - } - - if (cfg.id === "rfqVendorStatus") { - const statusVal = row.original.rfqVendorStatus - if (!statusVal) return null - // const Icon = getStatusIcon(statusVal) - const variant = statusVal === "INVITED" ? "default" : statusVal === "REJECTED" ? "destructive" : statusVal === "ACCEPTED" ? "secondary" : "outline" - return ( - <Badge variant={variant}> - {statusVal} - </Badge> - ) - } - - - if (cfg.id === "rfqVendorUpdated") { - const dateVal = cell.getValue() as Date - if (!dateVal) return null - return formatDate(dateVal) - } - - - // code etc... - return row.getValue(cfg.id) ?? "" - }, - } - - groupMap[groupName].push(childCol) - }) - - const commentsColumn: ColumnDef<MatchedVendorRow> = { - id: "comments", - header: ({ column }) => ( - <DataTableColumnHeaderSimple column={column} title="Comments" /> - ), - cell: ({ row }) => { - const vendor = row.original - const commCount = vendor.comments?.length ?? 0 - - function handleClick() { - // rowAction + openCommentSheet - setRowAction({ row, type: "comments" }) - openCommentSheet(Number(vendor.id) ?? 0) - } - - return ( - <Button - variant="ghost" - size="sm" - className="relative h-8 w-8 p-0 group" - onClick={handleClick} - aria-label={ - commCount > 0 ? `View ${commCount} comments` : "No comments" - } - > - <MessageSquare className="h-4 w-4 text-muted-foreground group-hover:text-primary transition-colors" /> - {commCount > 0 && ( - <Badge - variant="secondary" - className="pointer-events-none absolute -top-1 -right-1 h-4 min-w-[1rem] p-0 text-[0.625rem] leading-none flex items-center justify-center" - > - {commCount} - </Badge> - )} - <span className="sr-only"> - {commCount > 0 ? `${commCount} Comments` : "No Comments"} - </span> - </Button> - ) - }, - enableSorting: false, - maxSize: 80 - } - - // ---------------------------------------------------------------- - // 2) 액션컬럼 (빈 상태로 유지하여 에러 방지) - // ---------------------------------------------------------------- - const actionsColumn: ColumnDef<MatchedVendorRow> = { - id: "actions", - enableHiding: false, - cell: () => { - // 빈 셀 반환 (액션 없음) - return null - }, - size: 40, - enableSorting: false, - } - - // ---------------------------------------------------------------- - // 3-2) groupMap에서 실제 상위 컬럼(그룹)을 만들기 - // ---------------------------------------------------------------- - const nestedColumns: ColumnDef<MatchedVendorRow>[] = [] - - // 순서를 고정하고 싶다면 group 순서를 미리 정의하거나 sort해야 함 - // 여기서는 그냥 Object.entries 순서 - Object.entries(groupMap).forEach(([groupName, colDefs]) => { - if (groupName === "_noGroup") { - // 그룹 없음 → 그냥 최상위 레벨 컬럼 - nestedColumns.push(...colDefs) - } else { - // 상위 컬럼 - nestedColumns.push({ - id: groupName, - header: groupName, // "Basic Info", "Metadata" 등 - columns: colDefs, - }) - } - }) - - // ---------------------------------------------------------------- - // 4) 최종 컬럼 배열: select, nestedColumns, comments, actions - // ---------------------------------------------------------------- - return [ - selectColumn, - ...nestedColumns, - commentsColumn, - actionsColumn - ] -}
\ No newline at end of file diff --git a/lib/rfqs-tech/vendor-table/vendors-table-floating-bar.tsx b/lib/rfqs-tech/vendor-table/vendors-table-floating-bar.tsx deleted file mode 100644 index 9b32cf5f..00000000 --- a/lib/rfqs-tech/vendor-table/vendors-table-floating-bar.tsx +++ /dev/null @@ -1,137 +0,0 @@ -"use client" - -import * as React from "react" -import { SelectTrigger } from "@radix-ui/react-select" -import { type Table } from "@tanstack/react-table" -import { - ArrowUp, - CheckCircle2, - Download, - Loader, - Trash2, - X, -} from "lucide-react" -import { toast } from "sonner" - -import { exportTableToExcel } from "@/lib/export" -import { Button } from "@/components/ui/button" -import { Portal } from "@/components/ui/portal" -import { - Select, - SelectContent, - SelectGroup, - SelectItem, -} from "@/components/ui/select" -import { Separator } from "@/components/ui/separator" -import { - Tooltip, - TooltipContent, - TooltipTrigger, -} from "@/components/ui/tooltip" -import { Kbd } from "@/components/kbd" - -import { ActionConfirmDialog } from "@/components/ui/action-dialog" -import { vendors } from "@/db/schema/vendors" -import { MatchedVendorRow } from "@/config/vendorRfbColumnsConfig" - -interface VendorsTableFloatingBarProps { - table: Table<MatchedVendorRow> -} - - -export function VendorsTableFloatingBar({ table }: VendorsTableFloatingBarProps) { - const rows = table.getFilteredSelectedRowModel().rows - - const [isPending, startTransition] = React.useTransition() - const [action, setAction] = React.useState< - "update-status" | "export" | "delete" - >() - const [popoverOpen, setPopoverOpen] = React.useState(false) - - // Clear selection on Escape key press - React.useEffect(() => { - function handleKeyDown(event: KeyboardEvent) { - if (event.key === "Escape") { - table.toggleAllRowsSelected(false) - } - } - - window.addEventListener("keydown", handleKeyDown) - return () => window.removeEventListener("keydown", handleKeyDown) - }, [table]) - - - - // 공용 confirm dialog state - const [confirmDialogOpen, setConfirmDialogOpen] = React.useState(false) - const [confirmProps, setConfirmProps] = React.useState<{ - title: string - description?: string - onConfirm: () => Promise<void> | void - }>({ - title: "", - description: "", - onConfirm: () => { }, - }) - - - - - - return ( - <Portal > - <div className="fixed inset-x-0 bottom-10 z-50 mx-auto w-fit px-2.5" style={{ bottom: '1.5rem' }}> - <div className="w-full overflow-x-auto"> - <div className="mx-auto flex w-fit items-center gap-2 rounded-md border bg-background p-2 text-foreground shadow"> - <div className="flex h-7 items-center rounded-md border border-dashed pl-2.5 pr-1"> - <span className="whitespace-nowrap text-xs"> - {rows.length} selected - </span> - <Separator orientation="vertical" className="ml-2 mr-1" /> - <Tooltip> - <TooltipTrigger asChild> - <Button - variant="ghost" - size="icon" - className="size-5 hover:border" - onClick={() => table.toggleAllRowsSelected(false)} - > - <X className="size-3.5 shrink-0" aria-hidden="true" /> - </Button> - </TooltipTrigger> - <TooltipContent className="flex items-center border bg-accent px-2 py-1 font-semibold text-foreground dark:bg-zinc-900"> - <p className="mr-2">Clear selection</p> - <Kbd abbrTitle="Escape" variant="outline"> - Esc - </Kbd> - </TooltipContent> - </Tooltip> - </div> - - </div> - </div> - </div> - - - {/* 공용 Confirm Dialog */} - <ActionConfirmDialog - open={confirmDialogOpen} - onOpenChange={setConfirmDialogOpen} - title={confirmProps.title} - description={confirmProps.description} - onConfirm={confirmProps.onConfirm} - isLoading={isPending && (action === "delete" || action === "update-status")} - confirmLabel={ - action === "delete" - ? "Delete" - : action === "update-status" - ? "Update" - : "Confirm" - } - confirmVariant={ - action === "delete" ? "destructive" : "default" - } - /> - </Portal> - ) -} diff --git a/lib/rfqs-tech/vendor-table/vendors-table-toolbar-actions.tsx b/lib/rfqs-tech/vendor-table/vendors-table-toolbar-actions.tsx deleted file mode 100644 index 864d0f4b..00000000 --- a/lib/rfqs-tech/vendor-table/vendors-table-toolbar-actions.tsx +++ /dev/null @@ -1,84 +0,0 @@ -"use client" - -import * as React from "react" -import { type Table } from "@tanstack/react-table" - -import { MatchedVendorRow } from "@/config/vendorRfbColumnsConfig" -import { InviteVendorsDialog } from "./invite-vendors-dialog" -import { AddVendorDialog } from "./add-vendor-dialog" -import { Button } from "@/components/ui/button" -import { useToast } from "@/hooks/use-toast" - -interface VendorsTableToolbarActionsProps { - table: Table<MatchedVendorRow> - rfqId: number -} - -export function VendorsTableToolbarActions({ table, rfqId }: VendorsTableToolbarActionsProps) { - const { toast } = useToast() - const fileInputRef = React.useRef<HTMLInputElement>(null) - - // 선택된 모든 행 - const selectedRows = table.getFilteredSelectedRowModel().rows - - // 조건에 맞는 협력업체만 필터링 - const eligibleVendors = React.useMemo(() => { - return selectedRows - .map(row => row.original) - .filter(vendor => !vendor.rfqVendorStatus || vendor.rfqVendorStatus === "INVITED") - }, [selectedRows]) - - // 조건에 맞지 않는 협력업체 수 - const ineligibleCount = selectedRows.length - eligibleVendors.length - - function handleImportClick() { - fileInputRef.current?.click() - } - - function handleInviteClick() { - // 조건에 맞지 않는 협력업체가 있다면 토스트 메시지 표시 - if (ineligibleCount > 0) { - toast({ - title: "일부 협력업체만 초대됩니다", - description: `선택한 ${selectedRows.length}개 중 ${eligibleVendors.length}개만 초대 가능합니다. 나머지 ${ineligibleCount}개는 초대 불가능한 상태입니다.`, - // variant: "warning", - }) - } - } - - // 다이얼로그 표시 여부 - 적합한 협력업체가 1개 이상 있으면 표시 - const showInviteDialog = eligibleVendors.length > 0 - - return ( - <div className="flex items-center gap-2"> - {selectedRows.length > 0 && ( - <> - {showInviteDialog ? ( - <InviteVendorsDialog - vendors={eligibleVendors} - rfqId={rfqId} - onSuccess={() => table.toggleAllRowsSelected(false)} - onOpenChange={(open) => { - // 다이얼로그가 열릴 때만 경고 표시 - if (open && ineligibleCount > 0) { - handleInviteClick() - } - }} - /> - ) : ( - <Button - variant="default" - size="sm" - disabled={true} - title="선택된 협력업체 중 초대 가능한 협력업체가 없습니다" - > - 초대 불가 - </Button> - )} - </> - )} - - <AddVendorDialog rfqId={rfqId} /> - </div> - ) -}
\ No newline at end of file diff --git a/lib/rfqs-tech/vendor-table/vendors-table.tsx b/lib/rfqs-tech/vendor-table/vendors-table.tsx deleted file mode 100644 index 8a2c1ad9..00000000 --- a/lib/rfqs-tech/vendor-table/vendors-table.tsx +++ /dev/null @@ -1,199 +0,0 @@ -"use client" - -import * as React from "react" -import { useRouter } from "next/navigation" -import { useSession } from "next-auth/react" // Next-auth session hook 추가 -import type { - DataTableAdvancedFilterField, - DataTableFilterField, - DataTableRowAction, -} from "@/types/table" - -import { toSentenceCase } from "@/lib/utils" -import { useDataTable } from "@/hooks/use-data-table" -import { DataTable } from "@/components/data-table/data-table" -import { DataTableAdvancedToolbar } from "@/components/data-table/data-table-advanced-toolbar" -import { getColumns } from "./vendors-table-columns" -import { vendors } from "@/db/schema/vendors" -import { VendorsTableToolbarActions } from "./vendors-table-toolbar-actions" -import { fetchRfqAttachmentsbyCommentId, getMatchedVendors } from "../service" -import { InviteVendorsDialog } from "./invite-vendors-dialog" -import { CommentSheet, MatchedVendorComment } from "./comments-sheet" -import { MatchedVendorRow } from "@/config/vendorRfbColumnsConfig" -import { toast } from "sonner" - -interface VendorsTableProps { - promises: Promise<[Awaited<ReturnType<typeof getMatchedVendors>>]> - rfqId: number -} - -export function MatchedVendorsTable({ promises, rfqId }: VendorsTableProps) { - const { data: session } = useSession() // 세션 정보 가져오기 - - // 1) Suspense로 받아온 데이터 - const [{ data, pageCount }] = React.use(promises) - // data는 MatchedVendorRow[] 형태 (getMatchedVendors에서 반환) - - // 2) Row 액션 상태 - const [rowAction, setRowAction] = React.useState< - DataTableRowAction<MatchedVendorRow> | null - >(null) - - // **router** 획득 - const router = useRouter() - - // 3) CommentSheet 에 넣을 상태 - // => "댓글"은 MatchedVendorComment[] 로 관리해야 함 - const [initialComments, setInitialComments] = React.useState< - MatchedVendorComment[] - >([]) - - const [isLoadingComments, setIsLoadingComments] = React.useState(false) - - const [commentSheetOpen, setCommentSheetOpen] = React.useState(false) - const [selectedVendorIdForComments, setSelectedVendorIdForComments] = - React.useState<number | null>(null) - - // 5) 댓글 시트 오픈 함수 - columns보다 먼저 정의 - const openCommentSheet = React.useCallback(async function openCommentSheet(vendorId: number) { - // Clear previous comments - setInitialComments([]) - - // Start loading - setIsLoadingComments(true) - - // Open the sheet immediately with loading state - setSelectedVendorIdForComments(vendorId) - setCommentSheetOpen(true) - - // 현재 vendorId에 해당하는 row 찾기 - const currentRow = data.find(row => row.id === vendorId) - const comments = currentRow?.comments - - try { - if (comments && comments.length > 0) { - // (b) 각 comment마다 첨부파일 fetch - const commentWithAttachments: MatchedVendorComment[] = await Promise.all( - comments.map(async (c) => { - const attachments = await fetchRfqAttachmentsbyCommentId(c.id) - return { - ...c, - attachments, - } - }) - ) - setInitialComments(commentWithAttachments) - } - } catch (error) { - console.error("Error loading comments:", error) - toast.error("Failed to load comments") - } finally { - // End loading regardless of success/failure - setIsLoadingComments(false) - } - }, [data]) - - // 4) rowAction이 바뀌면, type이 "comments"인지 확인 후 open - React.useEffect(() => { - if (rowAction?.type === "comments") { - openCommentSheet(rowAction.row.original.id) - } - }, [rowAction, openCommentSheet]) - - // 6) 컬럼 정의 (memo) - const columns = React.useMemo( - () => getColumns({ setRowAction, router, openCommentSheet }), - [setRowAction, router, openCommentSheet] - ) - - // 7) 필터 정의 - const filterFields: DataTableFilterField<MatchedVendorRow>[] = [] - - const advancedFilterFields: DataTableAdvancedFilterField<MatchedVendorRow>[] = [ - { id: "vendorName", label: "Vendor Name", type: "text" }, - { id: "vendorCode", label: "Vendor Code", type: "text" }, - { id: "email", label: "Email", type: "text" }, - { id: "country", label: "Country", type: "text" }, - { - id: "vendorStatus", - label: "Vendor Status", - type: "multi-select", - options: vendors.status.enumValues.map((status) => ({ - label: toSentenceCase(status), - value: status, - })), - }, - { - id: "rfqVendorStatus", - label: "RFQ Status", - type: "multi-select", - options: ["INVITED", "ACCEPTED", "REJECTED", "QUOTED"].map((s) => ({ - label: s, - value: s, - })), - }, - { id: "rfqVendorUpdated", label: "Updated at", type: "date" }, - ] - - // 8) 테이블 생성 - const { table } = useDataTable({ - data, // MatchedVendorRow[] - columns, - pageCount, - filterFields, - enablePinning: true, - enableAdvancedFilter: true, - initialState: { - sorting: [{ id: "rfqVendorUpdated", desc: true }], - - }, - // 행의 고유 ID - getRowId: (originalRow) => String(originalRow.id), - shallow: false, - clearOnDefault: true, - }) - - // 세션에서 userId 추출하고 숫자로 변환 - const currentUserId = session?.user?.id ? parseInt(session.user.id, 10) : 0 - - return ( - <> - <DataTable - table={table} - > - <DataTableAdvancedToolbar - table={table} - filterFields={advancedFilterFields} - shallow={false} - > - <VendorsTableToolbarActions table={table} rfqId={rfqId} /> - </DataTableAdvancedToolbar> - </DataTable> - - {/* 초대 다이얼로그 */} - <InviteVendorsDialog - vendors={rowAction?.row.original ? [rowAction?.row.original] : []} - onOpenChange={() => setRowAction(null)} - rfqId={rfqId} - open={rowAction?.type === "invite"} - showTrigger={false} - /> - - {/* 댓글 시트 */} - <CommentSheet - open={commentSheetOpen} - onOpenChange={setCommentSheetOpen} - initialComments={initialComments} - rfqId={rfqId} - vendorId={selectedVendorIdForComments ?? 0} - currentUserId={currentUserId} - isLoading={isLoadingComments} // Pass the loading state - onCommentsUpdated={(updatedComments) => { - // Row 의 comments 필드도 업데이트 - if (!rowAction?.row) return - rowAction.row.original.comments = updatedComments - }} - /> - </> - ) -}
\ No newline at end of file diff --git a/lib/tbe-tech/table/tbe-table-columns.tsx b/lib/tbe-tech/table/tbe-table-columns.tsx deleted file mode 100644 index bb86e578..00000000 --- a/lib/tbe-tech/table/tbe-table-columns.tsx +++ /dev/null @@ -1,347 +0,0 @@ -"use client" - -import * as React from "react" -import { type DataTableRowAction } from "@/types/table" -import { type ColumnDef } from "@tanstack/react-table" -import { Download, MessageSquare } from "lucide-react" -import { toast } from "sonner" - -import { formatDate } from "@/lib/utils" -import { Badge } from "@/components/ui/badge" -import { Button } from "@/components/ui/button" -import { Checkbox } from "@/components/ui/checkbox" - -import { DataTableColumnHeaderSimple } from "@/components/data-table/data-table-column-simple-header" -import { useRouter } from "next/navigation" - -import { - - vendorTbeColumnsConfig, - VendorWithTbeFields, -} from "@/config/vendorTbeColumnsConfig" - -type NextRouter = ReturnType<typeof useRouter> - -interface GetColumnsProps { - setRowAction: React.Dispatch<React.SetStateAction<DataTableRowAction<VendorWithTbeFields> | null>> - router: NextRouter - openCommentSheet: (vendorId: number, rfqId: number) => void - openFilesDialog: (tbeId: number, vendorId: number, rfqId: number) => void - openVendorContactsDialog: (vendorId: number, vendor: VendorWithTbeFields) => void // 수정된 시그니처 - -} - - -/** - * tanstack table 컬럼 정의 (중첩 헤더 버전) - */ -export function getColumns({ - setRowAction, - router, - openCommentSheet, - openFilesDialog, - openVendorContactsDialog -}: GetColumnsProps): ColumnDef<VendorWithTbeFields>[] { - // ---------------------------------------------------------------- - // 1) Select 컬럼 (체크박스) - // ---------------------------------------------------------------- - const selectColumn: ColumnDef<VendorWithTbeFields> = { - id: "select", - header: ({ table }) => ( - <Checkbox - checked={ - table.getIsAllPageRowsSelected() || - (table.getIsSomePageRowsSelected() && "indeterminate") - } - onCheckedChange={(value) => table.toggleAllPageRowsSelected(!!value)} - aria-label="Select all" - className="translate-y-0.5" - /> - ), - cell: ({ row }) => ( - <Checkbox - checked={row.getIsSelected()} - onCheckedChange={(value) => row.toggleSelected(!!value)} - aria-label="Select row" - className="translate-y-0.5" - /> - ), - size: 40, - enableSorting: false, - enableHiding: false, - } - - // ---------------------------------------------------------------- - // 2) 그룹화(Nested) 컬럼 구성 - // ---------------------------------------------------------------- - const groupMap: Record<string, ColumnDef<VendorWithTbeFields>[]> = {} - - vendorTbeColumnsConfig.forEach((cfg) => { - const groupName = cfg.group || "_noGroup" - if (!groupMap[groupName]) { - groupMap[groupName] = [] - } - - // childCol: ColumnDef<VendorWithTbeFields> - const childCol: ColumnDef<VendorWithTbeFields> = { - accessorKey: cfg.id, - enableResizing: true, - header: ({ column }) => ( - <DataTableColumnHeaderSimple column={column} title={cfg.label} /> - ), - meta: { - excelHeader: cfg.excelHeader, - group: cfg.group, - type: cfg.type, - }, - // 셀 렌더링 - cell: ({ row, getValue }) => { - // 1) 필드값 가져오기 - const val = getValue() - - if (cfg.id === "vendorName") { - const vendor = row.original; - const vendorId = vendor.vendorId; - - // 협력업체 이름을 클릭할 수 있는 버튼으로 렌더링 - const handleVendorNameClick = () => { - if (vendorId) { - openVendorContactsDialog(vendorId, vendor); // vendor 전체 객체 전달 - } else { - toast.error("협력업체 ID를 찾을 수 없습니다."); - } - }; - - return ( - <Button - variant="link" - className="p-0 h-auto text-left font-normal justify-start hover:underline" - onClick={handleVendorNameClick} - > - {val as string} - </Button> - ); - } - if (cfg.id === "tbeResult") { - const vendor = row.original; - const tbeResult = vendor.tbeResult; - const filesCount = vendor.files?.length ?? 0; - - // Only show button or link if there are files - if (filesCount > 0) { - // Function to handle clicking on the result - const handleTbeResultClick = () => { - setRowAction({ row, type: "tbeResult" }); - }; - - if (!tbeResult) { - // No result yet, but files exist - show "결과 입력" button - return ( - <Button - variant="outline" - size="sm" - onClick={handleTbeResultClick} - > - 결과 입력 - </Button> - ); - } else { - // Result exists - show as a hyperlink - let badgeVariant: "default" | "outline" | "destructive" | "secondary" = "outline"; - - // Set badge variant based on result - if (tbeResult === "pass") { - badgeVariant = "default"; - } else if (tbeResult === "non-pass") { - badgeVariant = "destructive"; - } else if (tbeResult === "conditional pass") { - badgeVariant = "secondary"; - } - - return ( - <Button - variant="link" - className="p-0 h-auto underline" - onClick={handleTbeResultClick} - > - <Badge variant={badgeVariant}> - {tbeResult} - </Badge> - </Button> - ); - } - } - - // No files available, return empty cell - return null; - } - - - if (cfg.id === "vendorStatus") { - const statusVal = row.original.vendorStatus - if (!statusVal) return null - // const Icon = getStatusIcon(statusVal) - return ( - <Badge variant="outline"> - {statusVal} - </Badge> - ) - } - - - if (cfg.id === "rfqVendorStatus") { - const statusVal = row.original.rfqVendorStatus - if (!statusVal) return null - // const Icon = getStatusIcon(statusVal) - const variant = statusVal ==="INVITED"?"default" :statusVal ==="DECLINED"?"destructive":statusVal ==="ACCEPTED"?"secondary":"outline" - return ( - <Badge variant={variant}> - {statusVal} - </Badge> - ) - } - - // 예) TBE Updated (날짜) - if (cfg.id === "tbeUpdated") { - const dateVal = val as Date | undefined - if (!dateVal) return null - return formatDate(dateVal) - } - - // 그 외 필드는 기본 값 표시 - return val ?? "" - }, - } - - groupMap[groupName].push(childCol) - }) - - // groupMap → nestedColumns - const nestedColumns: ColumnDef<VendorWithTbeFields>[] = [] - Object.entries(groupMap).forEach(([groupName, colDefs]) => { - if (groupName === "_noGroup") { - nestedColumns.push(...colDefs) - } else { - nestedColumns.push({ - id: groupName, - header: groupName, - columns: colDefs, - }) - } - }) -// 파일 칼럼 -const filesColumn: ColumnDef<VendorWithTbeFields> = { - id: "files", - header: ({ column }) => ( - <DataTableColumnHeaderSimple column={column} title="Response Files" /> - ), - cell: ({ row }) => { - const vendor = row.original - const filesCount = vendor.files?.length ?? 0 - - function handleClick() { - // setRowAction으로 타입만 설정하고 끝내는 방법도 가능하지만 - // 혹은 바로 openFilesDialog()를 호출해도 됨. - setRowAction({ row, type: "files" }) - // 필요한 값을 직접 호출해서 넘겨줄 수도 있음. - openFilesDialog( - vendor.tbeId ?? 0, - vendor.vendorId ?? 0, - vendor.rfqId ?? 0, - ) - } - - return ( - <Button - variant="ghost" - size="sm" - className="relative h-8 w-8 p-0 group" - onClick={handleClick} - aria-label={filesCount > 0 ? `View ${filesCount} files` : "Upload file"} - > - <Download className="h-4 w-4" /> - {filesCount > 0 && ( - <Badge variant="secondary" className="absolute -top-1 -right-1 h-4 min-w-[1rem] p-0 text-[0.625rem] leading-none flex items-center justify-center"> - {filesCount} - </Badge> - )} - </Button> - ) - }, - enableSorting: false, - minSize: 80, -} - // ---------------------------------------------------------------- - // 2) 액션컬럼 (빈 상태로 유지하여 에러 방지) - // ---------------------------------------------------------------- - const actionsColumn: ColumnDef<VendorWithTbeFields> = { - id: "actions", - enableHiding: false, - cell: () => { - // 빈 셀 반환 (액션 없음) - return null - }, - size: 0, - enableSorting: false, - } - -// 댓글 칼럼 -const commentsColumn: ColumnDef<VendorWithTbeFields> = { - id: "comments", - header: ({ column }) => ( - <DataTableColumnHeaderSimple column={column} title="Comments" /> - ), - cell: ({ row }) => { - const vendor = row.original - const commCount = vendor.comments?.filter(c => c.evaluationId === vendor.tbeId)?.length ?? 0 - - function handleClick() { - // setRowAction() 로 type 설정 - setRowAction({ row, type: "comments" }) - // 필요하면 즉시 openCommentSheet() 직접 호출 - openCommentSheet( - vendor.vendorId ?? 0, - vendor.rfqId ?? 0, - ) - } - - return ( - <Button - variant="ghost" - size="sm" - className="relative h-8 w-8 p-0 group" - onClick={handleClick} - aria-label={ - commCount > 0 ? `View ${commCount} comments` : "No comments" - } - > - <MessageSquare className="h-4 w-4 text-muted-foreground group-hover:text-primary transition-colors" /> - {commCount > 0 && ( - <Badge - variant="secondary" - className="pointer-events-none absolute -top-1 -right-1 h-4 min-w-[1rem] p-0 text-[0.625rem] leading-none flex items-center justify-center" - > - {commCount} - </Badge> - )} - <span className="sr-only"> - {commCount > 0 ? `${commCount} Comments` : "No Comments"} - </span> - </Button> - ) - }, - enableSorting: false, - minSize: 80, -} -// ---------------------------------------------------------------- -// 5) 최종 컬럼 배열 - Update to include the files column -// ---------------------------------------------------------------- -return [ - selectColumn, - ...nestedColumns, - filesColumn, // Add the files column before comments - commentsColumn, - actionsColumn, -] - -}
\ No newline at end of file diff --git a/lib/tbe-tech/table/tbe-table-toolbar-actions.tsx b/lib/tbe-tech/table/tbe-table-toolbar-actions.tsx deleted file mode 100644 index d3502032..00000000 --- a/lib/tbe-tech/table/tbe-table-toolbar-actions.tsx +++ /dev/null @@ -1,68 +0,0 @@ -"use client" - -import * as React from "react" -import { type Table } from "@tanstack/react-table" -import { Download } from "lucide-react" -import { toast } from "sonner" -import { useRouter } from "next/navigation" - -import { exportTableToExcel } from "@/lib/export" -import { Button } from "@/components/ui/button" - -import { InviteVendorsDialog } from "@/lib/rfqs-tech/tbe-table/invite-vendors-dialog" -import { VendorWithTbeFields } from "@/config/vendorTbeColumnsConfig" - -interface VendorsTableToolbarActionsProps { - table: Table<VendorWithTbeFields> - rfqId: number -} - -export function VendorsTableToolbarActions({ table, rfqId }: VendorsTableToolbarActionsProps) { - const router = useRouter() - - const invitationPossibleVendors = React.useMemo(() => { - const selectedRows = table.getSelectedRowModel().rows; - const vendors = selectedRows.map(row => row.original); - - const rfqIds = new Set(vendors.map(vendor => vendor.rfqId)); - if (rfqIds.size > 1) { - toast.error("동일한 rfq에 대해 초대가 가능합니다"); - return []; - } - - return vendors.filter(vendor => vendor.technicalResponseStatus === null); - }, [table.getSelectedRowModel().rows]); - - const selectedRfqId = invitationPossibleVendors[0]?.rfqId ?? 0; - console.log("selectedRfqId", selectedRfqId) - - return ( - <div className="flex items-center gap-2"> - {invitationPossibleVendors.length > 0 && ( - <InviteVendorsDialog - vendors={invitationPossibleVendors} - rfqId={selectedRfqId} - onSuccess={() => { - table.toggleAllRowsSelected(false); - router.refresh(); - }} - showTrigger={true} - /> - )} - <Button - variant="outline" - size="sm" - onClick={() => - exportTableToExcel(table, { - filename: "tasks", - excludeColumns: ["select", "actions"], - }) - } - className="gap-2" - > - <Download className="size-4" aria-hidden="true" /> - <span className="hidden sm:inline">Export</span> - </Button> - </div> - ) -}
\ No newline at end of file diff --git a/lib/tbe-tech/table/tbe-table.tsx b/lib/tbe-tech/table/tbe-table.tsx deleted file mode 100644 index 16f86786..00000000 --- a/lib/tbe-tech/table/tbe-table.tsx +++ /dev/null @@ -1,305 +0,0 @@ -"use client" - -import * as React from "react" -import { useRouter } from "next/navigation" -import type { - DataTableAdvancedFilterField, - DataTableFilterField, - DataTableRowAction, -} from "@/types/table" - -import { toSentenceCase } from "@/lib/utils" -import { useDataTable } from "@/hooks/use-data-table" -import { DataTable } from "@/components/data-table/data-table" -import { DataTableAdvancedToolbar } from "@/components/data-table/data-table-advanced-toolbar" -import { getColumns } from "./tbe-table-columns" -import { vendors } from "@/db/schema/vendors" -import { CommentSheet, TbeComment } from "@/lib/rfqs-tech/tbe-table/comments-sheet" -import { VendorWithTbeFields } from "@/config/vendorTbeColumnsConfig" -import { TBEFileDialog } from "@/lib/rfqs-tech/tbe-table/file-dialog" -import { fetchRfqAttachmentsbyCommentId, getAllTBE } from "@/lib/rfqs-tech/service" -import { VendorsTableToolbarActions } from "./tbe-table-toolbar-actions" -import { TbeResultDialog } from "@/lib/rfqs-tech/tbe-table/tbe-result-dialog" -import { toast } from "sonner" -import { VendorContactsDialog } from "@/lib/rfqs-tech/tbe-table/vendor-contact-dialog" -import { InviteVendorsDialog } from "@/lib/rfqs-tech/tbe-table/invite-vendors-dialog" - -interface VendorsTableProps { - promises: Promise<[ - Awaited<ReturnType<typeof getAllTBE>>, - ]> -} - -export function AllTbeTable({ promises }: VendorsTableProps) { - const router = useRouter() - - // Suspense로 받아온 데이터 - const [{ data: rawData, pageCount }] = React.use(promises) - - // 벤더별로 데이터 그룹화 - const data = React.useMemo(() => { - const vendorMap = new Map<number, VendorWithTbeFields>() - - rawData.forEach((item) => { - const vendorId = item.vendorId - - if (vendorMap.has(vendorId)) { - // 기존 벤더 데이터가 있으면 파일과 댓글을 합침 - const existing = vendorMap.get(vendorId)! - - // 파일 합치기 (중복 제거) - const existingFileIds = new Set(existing.files.map(f => f.id)) - const newFiles = item.files.filter(f => !existingFileIds.has(f.id)) - existing.files = [...existing.files, ...newFiles] - - // 댓글 합치기 (중복 제거) - const existingCommentIds = new Set(existing.comments.map(c => c.id)) - const newComments = item.comments.filter(c => !existingCommentIds.has(c.id)) - existing.comments = [...existing.comments, ...newComments] - - } else { - // 새로운 벤더 데이터 추가 - vendorMap.set(vendorId, { - ...item, - // vendorResponseId: item.id, - technicalResponseId: item.id, - rfqId: item.rfqId - }) - } - }) - - return Array.from(vendorMap.values()) - }, [rawData]) - - const [rowAction, setRowAction] = React.useState<DataTableRowAction<VendorWithTbeFields> | null>(null) - - // 댓글 시트 관련 state - const [initialComments, setInitialComments] = React.useState<TbeComment[]>([]) - const [isLoadingComments, setIsLoadingComments] = React.useState(false) - - const [commentSheetOpen, setCommentSheetOpen] = React.useState(false) - const [selectedVendorIdForComments, setSelectedVendorIdForComments] = React.useState<number | null>(null) - const [selectedRfqIdForComments, setSelectedRfqIdForComments] = React.useState<number | null>(null) - - // 파일 다이얼로그 관련 state - const [isFileDialogOpen, setIsFileDialogOpen] = React.useState(false) - const [selectedVendorIdForFiles, setSelectedVendorIdForFiles] = React.useState<number | null>(null) - const [selectedTbeIdForFiles, setSelectedTbeIdForFiles] = React.useState<number | null>(null) - const [selectedRfqIdForFiles, setSelectedRfqIdForFiles] = React.useState<number | null>(null) - - const [isContactDialogOpen, setIsContactDialogOpen] = React.useState(false) - const [selectedVendor, setSelectedVendor] = React.useState<VendorWithTbeFields | null>(null) - const [selectedVendorId, setSelectedVendorId] = React.useState<number | null>(null) - - // 테이블 리프레시용 - const handleRefresh = React.useCallback(() => { - router.refresh(); - }, [router]); - - // ----------------------------------------------------------- - // 특정 action이 설정될 때마다 실행되는 effect - // ----------------------------------------------------------- - React.useEffect(() => { - if (!rowAction) return - - if (rowAction.type === "comments") { - openCommentSheet( - rowAction.row.original.vendorId ?? 0, - rowAction.row.original.rfqId ?? 0, - rowAction.row.original.tbeId ?? 0, - ) - } else if (rowAction.type === "files") { - openFilesDialog( - rowAction.row.original.tbeId ?? 0, - rowAction.row.original.vendorId ?? 0, - rowAction.row.original.rfqId ?? 0, - ) - } else if (rowAction.type === "invite") { - // 선택된 row 정보 로그 출력 - const selectedRows = table.getSelectedRowModel().rows - console.log("선택된 Row 정보:", { - selectedRows: selectedRows.map(row => ({ - rfqId: row.original.rfqId, - vendorId: row.original.vendorId, - vendorName: row.original.vendorName, - 전체데이터: row.original - })), - 총선택수: selectedRows.length - }) - - // 선택된 벤더들의 RFQ ID가 모두 동일한지 체크 - const rfqIds = new Set(selectedRows.map(row => row.original.rfqId)) - - if (rfqIds.size > 1) { - toast.error("동일한 rfq에 대해 초대가 가능합니다") - setRowAction(null) - return - } - - // 선택된 첫 번째 row의 rfqId 사용 - const selectedRfqId = selectedRows[0]?.original.rfqId - console.log("사용될 RFQ ID:", selectedRfqId) - } - }, [rowAction]) - - // ----------------------------------------------------------- - // 댓글 시트 열기 - // ----------------------------------------------------------- - async function openCommentSheet(vendorId: number, rfqId: number, tbeId?: number) { - setInitialComments([]) - setIsLoadingComments(true) - const comments = rowAction?.row.original.comments?.filter(c => c.evaluationId === tbeId) - try { - if (comments && comments.length > 0) { - const commentWithAttachments: TbeComment[] = await Promise.all( - comments.map(async (c) => { - const attachments = await fetchRfqAttachmentsbyCommentId(c.id) - return { - ...c, - commentedBy: 1, // DB나 API 응답에 있다고 가정 - attachments, - } - }) - ) - setInitialComments(commentWithAttachments) - } - - setSelectedVendorIdForComments(vendorId) - setSelectedRfqIdForComments(rfqId) - setCommentSheetOpen(true) - } catch (error) { - console.error("Error loading comments:", error) - toast.error("Failed to load comments") - } finally { - // End loading regardless of success/failure - setIsLoadingComments(false) - } - } - - // ----------------------------------------------------------- - // 파일 다이얼로그 열기 - // ----------------------------------------------------------- - const openFilesDialog = (tbeId: number, vendorId: number, rfqId: number) => { - setSelectedTbeIdForFiles(tbeId) - setSelectedVendorIdForFiles(vendorId) - setSelectedRfqIdForFiles(rfqId) - setIsFileDialogOpen(true) - } - - const openVendorContactsDialog = (vendorId: number, vendor: VendorWithTbeFields) => { - setSelectedVendorId(vendorId) - setSelectedVendor(vendor) - setIsContactDialogOpen(true) - } - - - // ----------------------------------------------------------- - // 테이블 컬럼 - // ----------------------------------------------------------- - const columns = React.useMemo( - () => - getColumns({ - setRowAction, - router, - openCommentSheet, // 필요하면 직접 호출 가능 - openFilesDialog, - openVendorContactsDialog, - }), - [setRowAction, router] - ) - - // ----------------------------------------------------------- - // 필터 필드 - // ----------------------------------------------------------- - const filterFields: DataTableFilterField<VendorWithTbeFields>[] = [ - // 예: 표준 필터 - ] - const advancedFilterFields: DataTableAdvancedFilterField<VendorWithTbeFields>[] = [ - { id: "vendorName", label: "Vendor Name", type: "text" }, - { id: "vendorCode", label: "Vendor Code", type: "text" }, - { id: "email", label: "Email", type: "text" }, - { id: "country", label: "Country", type: "text" }, - { - id: "vendorStatus", - label: "Vendor Status", - type: "multi-select", - options: vendors.status.enumValues.map((status) => ({ - label: toSentenceCase(status), - value: status, - })), - }, - { id: "rfqVendorUpdated", label: "Updated at", type: "date" }, - ] - - // ----------------------------------------------------------- - // 테이블 생성 훅 - // ----------------------------------------------------------- - const { table } = useDataTable({ - data, - columns, - pageCount, - filterFields, - enablePinning: true, - enableAdvancedFilter: true, - initialState: { - sorting: [{ id: "rfqVendorUpdated", desc: true }], - columnPinning: { right: ["files", "comments"] }, - }, - getRowId: (originalRow) => (`${originalRow.id}${originalRow.rfqId}`), - shallow: false, - clearOnDefault: true, - }) - - return ( - <> - <DataTable table={table}> - <DataTableAdvancedToolbar - table={table} - filterFields={advancedFilterFields} - shallow={false} - > - <VendorsTableToolbarActions - table={table} - rfqId={table.getSelectedRowModel().rows[0]?.original.rfqId ?? 0} - /> - </DataTableAdvancedToolbar> - </DataTable> - - {/* 댓글 시트 */} - <CommentSheet - currentUserId={1} - open={commentSheetOpen} - tbeId={selectedTbeIdForFiles ?? 0} - onOpenChange={setCommentSheetOpen} - vendorId={selectedVendorIdForComments ?? 0} - rfqId={selectedRfqIdForComments ?? 0} - isLoading={isLoadingComments} - initialComments={initialComments} - /> - - {/* 파일 업로드/다운로드 다이얼로그 */} - <TBEFileDialog - isOpen={isFileDialogOpen} - onOpenChange={setIsFileDialogOpen} - tbeId={selectedTbeIdForFiles ?? 0} - vendorId={selectedVendorIdForFiles ?? 0} - rfqId={selectedRfqIdForFiles ?? 0} - onRefresh={handleRefresh} - /> - - <TbeResultDialog - open={rowAction?.type === "tbeResult"} - onOpenChange={() => setRowAction(null)} - tbe={rowAction?.row.original ?? null} - /> - - <VendorContactsDialog - isOpen={isContactDialogOpen} - onOpenChange={setIsContactDialogOpen} - vendorId={selectedVendorId} - vendor={selectedVendor} - /> - - </> - ) -}
\ No newline at end of file |
