summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--lib/cbe-tech/table/cbe-table-columns.tsx241
-rw-r--r--lib/cbe-tech/table/cbe-table-toolbar-actions.tsx72
-rw-r--r--lib/cbe-tech/table/cbe-table.tsx192
-rw-r--r--lib/rfqs-tech/cbe-table/cbe-table-columns.tsx257
-rw-r--r--lib/rfqs-tech/cbe-table/cbe-table-toolbar-actions.tsx67
-rw-r--r--lib/rfqs-tech/cbe-table/cbe-table.tsx178
-rw-r--r--lib/rfqs-tech/cbe-table/comments-sheet.tsx328
-rw-r--r--lib/rfqs-tech/cbe-table/invite-vendors-dialog.tsx423
-rw-r--r--lib/rfqs-tech/cbe-table/vendor-contact-dialog.tsx71
-rw-r--r--lib/rfqs-tech/repository.ts222
-rw-r--r--lib/rfqs-tech/service.ts3678
-rw-r--r--lib/rfqs-tech/table/ItemsDialog.tsx754
-rw-r--r--lib/rfqs-tech/table/add-rfq-dialog.tsx295
-rw-r--r--lib/rfqs-tech/table/attachment-rfq-sheet.tsx426
-rw-r--r--lib/rfqs-tech/table/delete-rfqs-dialog.tsx149
-rw-r--r--lib/rfqs-tech/table/feature-flags-provider.tsx108
-rw-r--r--lib/rfqs-tech/table/feature-flags.tsx96
-rw-r--r--lib/rfqs-tech/table/rfqs-table-columns.tsx308
-rw-r--r--lib/rfqs-tech/table/rfqs-table-floating-bar.tsx338
-rw-r--r--lib/rfqs-tech/table/rfqs-table-toolbar-actions.tsx52
-rw-r--r--lib/rfqs-tech/table/rfqs-table.tsx254
-rw-r--r--lib/rfqs-tech/table/update-rfq-sheet.tsx243
-rw-r--r--lib/rfqs-tech/tbe-table/comments-sheet.tsx325
-rw-r--r--lib/rfqs-tech/tbe-table/feature-flags-provider.tsx108
-rw-r--r--lib/rfqs-tech/tbe-table/file-dialog.tsx139
-rw-r--r--lib/rfqs-tech/tbe-table/invite-vendors-dialog.tsx227
-rw-r--r--lib/rfqs-tech/tbe-table/tbe-result-dialog.tsx208
-rw-r--r--lib/rfqs-tech/tbe-table/tbe-table-columns.tsx360
-rw-r--r--lib/rfqs-tech/tbe-table/tbe-table-toolbar-actions.tsx67
-rw-r--r--lib/rfqs-tech/tbe-table/tbe-table.tsx243
-rw-r--r--lib/rfqs-tech/tbe-table/vendor-contact-dialog.tsx71
-rw-r--r--lib/rfqs-tech/tbe-table/vendor-contact/vendor-contact-table-column.tsx70
-rw-r--r--lib/rfqs-tech/tbe-table/vendor-contact/vendor-contact-table.tsx89
-rw-r--r--lib/rfqs-tech/validations.ts284
-rw-r--r--lib/rfqs-tech/vendor-table/add-vendor-dialog.tsx37
-rw-r--r--lib/rfqs-tech/vendor-table/comments-sheet.tsx318
-rw-r--r--lib/rfqs-tech/vendor-table/feature-flags-provider.tsx108
-rw-r--r--lib/rfqs-tech/vendor-table/invite-vendors-dialog.tsx173
-rw-r--r--lib/rfqs-tech/vendor-table/vendor-list/vendor-list-table-column.tsx152
-rw-r--r--lib/rfqs-tech/vendor-table/vendor-list/vendor-list-table.tsx176
-rw-r--r--lib/rfqs-tech/vendor-table/vendors-table-columns.tsx219
-rw-r--r--lib/rfqs-tech/vendor-table/vendors-table-floating-bar.tsx137
-rw-r--r--lib/rfqs-tech/vendor-table/vendors-table-toolbar-actions.tsx84
-rw-r--r--lib/rfqs-tech/vendor-table/vendors-table.tsx199
-rw-r--r--lib/tbe-tech/table/tbe-table-columns.tsx347
-rw-r--r--lib/tbe-tech/table/tbe-table-toolbar-actions.tsx68
-rw-r--r--lib/tbe-tech/table/tbe-table.tsx305
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