summaryrefslogtreecommitdiff
path: root/lib
diff options
context:
space:
mode:
authordujinkim <dujin.kim@dtsolution.co.kr>2025-05-28 12:26:28 +0000
committerdujinkim <dujin.kim@dtsolution.co.kr>2025-05-28 12:26:28 +0000
commit36dd60ca6fce7712b35e6d7c1b9602710f442ada (patch)
tree32c3f6e2eef53b565d545535b10b7980ad184883 /lib
parent2caa8093ac616f14d48430ce2f485f805d6faa53 (diff)
(최겸) 기술영업 해양 rfq 개발v1
Diffstat (limited to 'lib')
-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/items-tech/repository.ts7
-rw-r--r--lib/items-tech/service.ts10
-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.ts3998
-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.tsx154
-rw-r--r--lib/rfqs-tech/vendor-table/vendor-list/vendor-list-table.tsx142
-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.tsx304
49 files changed, 13568 insertions, 2 deletions
diff --git a/lib/cbe-tech/table/cbe-table-columns.tsx b/lib/cbe-tech/table/cbe-table-columns.tsx
new file mode 100644
index 00000000..2da62ea8
--- /dev/null
+++ b/lib/cbe-tech/table/cbe-table-columns.tsx
@@ -0,0 +1,241 @@
+"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
new file mode 100644
index 00000000..44a79b37
--- /dev/null
+++ b/lib/cbe-tech/table/cbe-table-toolbar-actions.tsx
@@ -0,0 +1,72 @@
+"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
new file mode 100644
index 00000000..0cd5aec0
--- /dev/null
+++ b/lib/cbe-tech/table/cbe-table.tsx
@@ -0,0 +1,192 @@
+"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/items-tech/repository.ts b/lib/items-tech/repository.ts
index 43e88866..1f4f7933 100644
--- a/lib/items-tech/repository.ts
+++ b/lib/items-tech/repository.ts
@@ -1,6 +1,6 @@
// src/lib/items/repository.ts
import db from "@/db/db";
-import { Item, items } from "@/db/schema/items";
+import { Item, ItemOffshoreTop, ItemOffshoreHull, itemOffshoreHull, itemOffshoreTop, items } from "@/db/schema/items";
import {
eq,
inArray,
@@ -117,3 +117,8 @@ export async function updateItems(
export async function findAllItems(): Promise<Item[]> {
return db.select().from(items).orderBy(asc(items.itemCode));
}
+export async function findAllOffshoreItems(): Promise<(ItemOffshoreHull | ItemOffshoreTop)[]> {
+ const hullItems = await db.select().from(itemOffshoreHull);
+ const topItems = await db.select().from(itemOffshoreTop);
+ return [...hullItems, ...topItems];
+}
diff --git a/lib/items-tech/service.ts b/lib/items-tech/service.ts
index 62a66aaa..70c664f3 100644
--- a/lib/items-tech/service.ts
+++ b/lib/items-tech/service.ts
@@ -10,8 +10,9 @@ import { getErrorMessage } from "@/lib/handle-error";
import { asc, desc, ilike, and, or, eq, count, inArray, sql } from "drizzle-orm";
import { GetItemsSchema, UpdateItemSchema, ShipbuildingItemCreateData, TypedItemCreateData, OffshoreTopItemCreateData, OffshoreHullItemCreateData } from "./validations";
-import { Item, items, itemShipbuilding, itemOffshoreTop, itemOffshoreHull } from "@/db/schema/items";
+import { Item, items, itemShipbuilding, itemOffshoreTop, itemOffshoreHull, ItemOffshoreTop, ItemOffshoreHull } from "@/db/schema/items";
import { findAllItems } from "./repository";
+import { findAllOffshoreItems } from "./repository";
/* -----------------------------------------------------
1) 조회 관련
@@ -1000,3 +1001,10 @@ export async function getAllShipbuildingItems(): Promise<Item[]> {
throw new Error("Failed to get items");
}
}
+export async function getAllOffshoreItems(): Promise<(ItemOffshoreHull | ItemOffshoreTop)[]> {
+ try {
+ return await findAllOffshoreItems();
+ } catch (err) {
+ throw new Error("Failed to get items");
+ }
+}
diff --git a/lib/rfqs-tech/cbe-table/cbe-table-columns.tsx b/lib/rfqs-tech/cbe-table/cbe-table-columns.tsx
new file mode 100644
index 00000000..11ce9ccf
--- /dev/null
+++ b/lib/rfqs-tech/cbe-table/cbe-table-columns.tsx
@@ -0,0 +1,257 @@
+"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
new file mode 100644
index 00000000..464bf988
--- /dev/null
+++ b/lib/rfqs-tech/cbe-table/cbe-table-toolbar-actions.tsx
@@ -0,0 +1,67 @@
+"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
new file mode 100644
index 00000000..37fbc3f4
--- /dev/null
+++ b/lib/rfqs-tech/cbe-table/cbe-table.tsx
@@ -0,0 +1,178 @@
+"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
new file mode 100644
index 00000000..e91a0617
--- /dev/null
+++ b/lib/rfqs-tech/cbe-table/comments-sheet.tsx
@@ -0,0 +1,328 @@
+"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
new file mode 100644
index 00000000..18edbe80
--- /dev/null
+++ b/lib/rfqs-tech/cbe-table/invite-vendors-dialog.tsx
@@ -0,0 +1,423 @@
+"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
new file mode 100644
index 00000000..180db392
--- /dev/null
+++ b/lib/rfqs-tech/cbe-table/vendor-contact-dialog.tsx
@@ -0,0 +1,71 @@
+"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
new file mode 100644
index 00000000..6223e97b
--- /dev/null
+++ b/lib/rfqs-tech/repository.ts
@@ -0,0 +1,222 @@
+// 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
new file mode 100644
index 00000000..cd4aeaf7
--- /dev/null
+++ b/lib/rfqs-tech/service.ts
@@ -0,0 +1,3998 @@
+// src/lib/tasks/service.ts
+"use server"; // Next.js 서버 액션에서 직접 import하려면 (선택)
+
+import { revalidatePath, revalidateTag, unstable_noStore } from "next/cache";
+import db from "@/db/db";
+
+import { filterColumns } from "@/lib/filter-columns";
+import { unstable_cache } from "@/lib/unstable-cache";
+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 fs from "fs/promises";
+import { randomUUID } from "crypto";
+import { writeFile, mkdir } from 'fs/promises'
+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";
+
+interface InviteVendorsInput {
+ rfqId: number
+ vendorIds: number[]
+}
+
+/* -----------------------------------------------------
+ 1) 조회 관련
+----------------------------------------------------- */
+
+/**
+ * 복잡한 조건으로 Rfq 목록을 조회 (+ pagination) 하고,
+ * 총 개수에 따라 pageCount를 계산해서 리턴.
+ * Next.js의 unstable_cache를 사용해 일정 시간 캐시.
+ */
+export async function getRfqs(input: GetRfqsSchema) {
+ return unstable_cache(
+ async () => {
+ 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 };
+ }
+ },
+ [JSON.stringify(input)],
+ {
+ revalidate: 3600,
+ tags: [`rfqs`],
+ }
+ )();
+}
+
+/** Status별 개수 */
+export async function getRfqStatusCounts() {
+ return unstable_cache(
+ async () => {
+ 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>;
+ }
+ },
+ [`rfq-status-counts`],
+ {
+ revalidate: 3600,
+ tags: [`rfqs`],
+ }
+ )();
+}
+
+
+
+/* -----------------------------------------------------
+ 2) 생성(Create)
+----------------------------------------------------- */
+
+/**
+ * Rfq 생성 후, (가장 오래된 Rfq 1개) 삭제로
+ * 전체 Rfq 개수를 고정
+ */
+export async function createRfq(input: CreateRfqSchema) {
+ unstable_noStore();
+ 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,
+ });
+ });
+
+ revalidateTag("rfqs");
+
+ return { data: null, error: null };
+ } catch (err) {
+ return { data: null, error: getErrorMessage(err) };
+ }
+}
+
+/* -----------------------------------------------------
+ 3) 업데이트
+----------------------------------------------------- */
+
+/** 단건 업데이트 */
+export async function modifyRfq(input: UpdateRfqSchema & { id: number }) {
+ unstable_noStore();
+ 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,
+ });
+ });
+
+ revalidateTag("rfqs");
+
+ 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
+}) {
+ unstable_noStore();
+ try {
+ await db.transaction(async (tx) => {
+ await updateRfqs(tx, input.ids, {
+ status: input.status,
+ dueDate: input.dueDate,
+ });
+ });
+
+ revalidateTag("rfqs");
+
+ return { data: null, error: null };
+ } catch (err) {
+ return { data: null, error: getErrorMessage(err) };
+ }
+}
+
+
+/* -----------------------------------------------------
+ 4) 삭제
+----------------------------------------------------- */
+
+/** 단건 삭제 */
+export async function removeRfq(input: { id: number }) {
+ unstable_noStore();
+ try {
+ await db.transaction(async (tx) => {
+ // 삭제
+ await deleteRfqById(tx, input.id);
+ // 바로 새 Rfq 생성
+ });
+
+ revalidateTag("rfqs");
+
+ return { data: null, error: null };
+ } catch (err) {
+ return { data: null, error: getErrorMessage(err) };
+ }
+}
+
+/** 복수 삭제 */
+export async function removeRfqs(input: { ids: number[] }) {
+ unstable_noStore();
+ try {
+ await db.transaction(async (tx) => {
+ // 삭제
+ await deleteRfqsByIds(tx, input.ids);
+ });
+
+ revalidateTag("rfqs");
+
+ return { data: null, error: null };
+ } catch (err) {
+ return { data: null, error: getErrorMessage(err) };
+ }
+}
+
+
+/**
+ * RFQ 아이템 삭제 함수
+ */
+export async function deleteRfqItem(input: { id: number, rfqId: number }) {
+ unstable_noStore(); // Next.js 서버 액션 캐싱 방지
+
+ try {
+ // 삭제 작업 수행
+ await db
+ .delete(rfqItems)
+ .where(
+ and(
+ eq(rfqItems.id, input.id),
+ eq(rfqItems.rfqId, input.rfqId)
+ )
+ );
+
+ console.log(`Deleted RFQ item: ${input.id} for RFQ ${input.rfqId}`);
+
+ // 캐시 무효화
+ revalidateTag("rfqs");
+ revalidateTag(`rfq-${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 }) {
+ unstable_noStore();
+
+ 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));
+
+ console.log(`Updated RFQ item with 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}`);
+ }
+ }
+ });
+
+ // 캐시 무효화
+ revalidateTag("rfqs");
+ revalidateTag(`rfq-${input.rfqId}`);
+
+ 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) {
+ // filePath: 예) "/rfq/123/...xyz"
+ const absolutePath = path.join(
+ process.cwd(),
+ "public",
+ row.filePath.replace(/^\/+/, "") // 슬래시 제거
+ );
+ try {
+ await fs.unlink(absolutePath);
+ } catch (err) {
+ console.error("File remove error:", err);
+ }
+ }
+ }
+
+ // 2) 새 파일 업로드
+ if (newFiles.length > 0) {
+ const rfqDir = path.join("public", "rfq", String(rfqId));
+ // 폴더 없으면 생성
+ await fs.mkdir(rfqDir, { recursive: true });
+
+ for (const file of newFiles) {
+ // 2-1) DRM 복호화 시도 ----------------------------------------------------------------------
+ // decryptWithServerAction 함수는 오류 처리 및 원본 반환 로직을 포함하고 있음 (해제 실패시 원본 반환)
+ // 이후 코드가 buffer로 작업하므로 buffer로 전환한다.
+ const decryptedData = await decryptWithServerAction(file);
+ const buffer = Buffer.from(decryptedData);
+ // -----------------------------------------------------------------------------------------
+
+
+ // 2-2) 고유 파일명
+ const uniqueName = `${randomUUID()}-${file.name}`;
+ // 예) "rfq/123/xxx"
+ const relativePath = path.join("rfq", String(rfqId), uniqueName);
+ const absolutePath = path.join("public", relativePath);
+
+ // 2-3) 파일 저장
+ await fs.writeFile(absolutePath, buffer);
+
+ // 2-4) DB Insert
+ await db.insert(rfqAttachments).values({
+ rfqId,
+ vendorId,
+ fileName: file.name,
+ filePath: "/" + relativePath.replace(/\\/g, "/"),
+ // (Windows 경로 대비)
+ });
+ }
+ }
+
+ const [countRow] = await db
+ .select({ cnt: sql<number>`count(*)`.as("cnt") })
+ .from(rfqAttachments)
+ .where(eq(rfqAttachments.rfqId, rfqId));
+
+ const newCount = countRow?.cnt ?? 0;
+
+ // 3) revalidateTag 등 캐시 무효화
+ revalidateTag("rfq-attachments");
+
+ 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) {
+ return unstable_cache(
+ async () => {
+ // ─────────────────────────────────────────────────────
+ // 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 }
+ },
+ [JSON.stringify({ input, rfqId })],
+ { revalidate: 3600, tags: ["vendors", `rfq-${rfqId}`] }
+ )()
+}
+
+export async function inviteVendors(input: InviteVendorsInput) {
+ unstable_noStore() // 서버 액션 캐싱 방지
+ 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`
+
+ // 이메일 전송 오류를 기록할 배열
+ 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`)
+ })
+
+ // 캐시 무효화
+ revalidateTag("tbe-vendors")
+ revalidateTag("all-tbe-vendors")
+ revalidateTag("rfq-vendors")
+ revalidateTag("cbe-vendors")
+ revalidateTag("rfqs")
+ revalidateTag(`rfq-${rfqId}`)
+ // revalidateTag("rfqs");
+ // revalidateTag(`rfq-${rfqId}`);
+ // revalidateTag("vendors");
+ // revalidateTag("rfq-vendors");
+ // vendorIds.forEach(vendorId => {
+ // revalidateTag(`vendor-${vendorId}`);
+ // });
+
+ // 이메일 오류가 있었는지 확인
+ 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) {
+ return unstable_cache(
+ async () => {
+ // 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 }
+ },
+ [JSON.stringify({ input, rfqId })],
+ {
+ revalidate: 3600,
+ tags: ["tbe", `rfq-${rfqId}`],
+ }
+ )()
+}
+
+export async function getTBEforVendor(input: GetTBESchema, vendorId: number) {
+
+ if (isNaN(vendorId) || vendorId === null || vendorId === undefined) {
+ throw new Error("유효하지 않은 vendorId: 숫자 값이 필요합니다");
+ }
+
+ return unstable_cache(
+ async () => {
+ // 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 }
+ },
+ [JSON.stringify(input), String(vendorId)], // 캐싱 키에 packagesId 추가
+ {
+ revalidate: 3600,
+ tags: [`tbe-vendor-${vendorId}`],
+ }
+ )()
+}
+
+export async function inviteTbeVendorsAction(formData: FormData) {
+ // 캐싱 방지
+ unstable_noStore()
+
+ 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.")
+ }
+
+ // /public/rfq/[rfqId] 경로
+ const uploadDir = path.join(process.cwd(), "public", "rfq", String(rfqId))
+
+ // 디렉토리가 없다면 생성
+ try {
+ await fs.mkdir(uploadDir, { recursive: true })
+ } catch (err) {
+ console.error("디렉토리 생성 실패:", err)
+ }
+
+ // 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 originalName = file.name || "tbe-sheet.xlsx"
+ // 파일명 충돌 방지를 위한 타임스탬프 추가
+ const timestamp = new Date().getTime()
+ const fileName = `${timestamp}-${originalName}`
+ const savePath = path.join(uploadDir, fileName)
+
+ // 파일 ArrayBuffer → Buffer 변환 후 저장
+ const arrayBuffer = await file.arrayBuffer()
+ await fs.writeFile(savePath, Buffer.from(arrayBuffer))
+
+ // 저장 경로 & 파일명 기록
+ savedFiles.push({
+ fileName: originalName, // 원본 파일명으로 첨부
+ filePath: `/rfq/${rfqId}/${fileName}`, // public 이하 경로
+ absolutePath: savePath,
+ })
+ }
+
+ // (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`
+
+ 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)
+ }
+ }
+ }
+
+ // 6) 캐시 무효화
+ revalidateTag("tbe")
+ revalidateTag("vendors")
+ revalidateTag(`rfq-${rfqId}`)
+ revalidateTag("tbe-vendors")
+
+ })
+
+ // 성공
+ return { error: null }
+ } catch (err) {
+ console.error("[inviteTbeVendorsAction] Error:", err)
+ return { error: getErrorMessage(err) }
+ }
+}
+////partners
+
+
+export async function modifyRfqVendor(input: UpdateRfqVendorSchema) {
+ unstable_noStore();
+ try {
+ const data = await db.transaction(async (tx) => {
+ const [res] = await updateRfqVendor(tx, input.id, {
+ responseStatus: input.status,
+ });
+ return res;
+ });
+
+ revalidateTag("vendors");
+ revalidateTag("tbe");
+
+ 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) {
+
+ const rfqDir = path.join(process.cwd(), "public", "rfq", String(rfqId));
+ // 폴더 없으면 생성
+ await fs.mkdir(rfqDir, { recursive: true });
+
+ for (const file of files) {
+ const ab = await file.arrayBuffer();
+ const buffer = Buffer.from(ab);
+
+ // 2-2) 고유 파일명
+ const uniqueName = `${randomUUID()}-${file.name}`;
+ // 예) "rfq/123/xxx"
+ const relativePath = path.join("rfq", String(rfqId), uniqueName);
+ const absolutePath = path.join(process.cwd(), "public", relativePath);
+
+ // 2-3) 파일 저장
+ await fs.writeFile(absolutePath, buffer);
+
+ // DB에 첨부파일 row 생성
+ await db.insert(rfqAttachments).values({
+ rfqId,
+ vendorId: vendorId || null,
+ evaluationId: evaluationId || null,
+ cbeId: cbeId || null,
+ commentId: insertedComment.id, // 새 코멘트와 연결
+ fileName: file.name,
+ filePath: "/" + relativePath.replace(/\\/g, "/"),
+ })
+ }
+ }
+
+ revalidateTag("rfq-vendors");
+ revalidateTag(`rfq-${rfqId}`);
+
+ if (vendorId) {
+ revalidateTag(`vendor-${vendorId}`);
+ }
+
+ if (evaluationId) {
+ revalidateTag("tbe");
+ revalidateTag(`tbe-vendor-${vendorId}`);
+ revalidateTag("all-tbe-vendors");
+ }
+
+ if (cbeId) {
+ revalidateTag("cbe");
+ revalidateTag(`cbe-vendor-${vendorId}`);
+ revalidateTag("all-cbe-vendors");
+ }
+
+ 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.")
+ }
+ revalidateTag("rfq-vendors");
+ revalidateTag("tbe");
+ revalidateTag("cbe");
+
+ 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 []; // 오류 발생 시 빈 배열 반환
+ }
+}
+
+
+// 반환 타입 명시적 정의 - rfqCode가 null일 수 있음을 반영
+export interface BudgetaryRfq {
+ id: number;
+ rfqCode: string | null; // null 허용으로 변경
+ description: string | null;
+ projectId: number | null;
+ projectCode: string | null;
+ projectName: string | null;
+}
+
+type GetBudgetaryRfqsResponse =
+ | { rfqs: BudgetaryRfq[]; totalCount: number; error?: never }
+ | { error: string; rfqs?: never; totalCount: number }
+/**
+ * Budgetary 타입의 RFQ 목록을 가져오는 서버 액션
+ * Purchase RFQ 생성 시 부모 RFQ로 선택할 수 있도록 함
+ * 페이징 및 필터링 기능 포함
+ */
+export interface GetBudgetaryRfqsParams {
+ search?: string;
+ projectId?: number;
+ rfqId?: number; // 특정 ID로 단일 RFQ 검색
+
+ limit?: number;
+ offset?: number;
+}
+
+export async function getBudgetaryRfqs(params: GetBudgetaryRfqsParams = {}): Promise<GetBudgetaryRfqsResponse> {
+ const { search, projectId, rfqId, limit = 50, offset = 0 } = params;
+ const cacheKey = `rfqs-query-${JSON.stringify(params)}`;
+
+ return unstable_cache(
+ async () => {
+ try {
+ // 기본 검색 조건 구성
+ let baseCondition;
+
+
+
+ // 특정 ID로 검색하는 경우
+ if (rfqId) {
+ baseCondition = and(baseCondition, eq(rfqs.id, rfqId));
+ }
+
+ let where1;
+ // 검색어 조건 추가 (있을 경우)
+ if (search && search.trim()) {
+ const searchTerm = `%${search.trim()}%`;
+ const searchCondition = or(
+ ilike(rfqs.rfqCode, searchTerm),
+ ilike(rfqs.description, searchTerm),
+ ilike(projects.code, searchTerm),
+ ilike(projects.name, searchTerm)
+ );
+ where1 = searchCondition;
+ }
+
+ let where2;
+ // 프로젝트 ID 조건 추가 (있을 경우)
+ if (projectId) {
+ where2 = eq(rfqs.projectId, projectId);
+ }
+
+ const finalWhere = and(baseCondition, where1, where2);
+
+ // 총 개수 조회
+ const [countResult] = await db
+ .select({ count: count() })
+ .from(rfqs)
+ .leftJoin(projects, eq(rfqs.projectId, projects.id))
+ .where(finalWhere);
+
+ // 실제 데이터 조회
+ const resultRfqs = await db
+ .select({
+ id: rfqs.id,
+ rfqCode: rfqs.rfqCode,
+ description: rfqs.description,
+ projectId: rfqs.projectId,
+ projectCode: projects.code,
+ projectName: projects.name,
+ })
+ .from(rfqs)
+ .leftJoin(projects, eq(rfqs.projectId, projects.id))
+ .where(finalWhere)
+ .orderBy(desc(rfqs.createdAt))
+ .limit(limit)
+ .offset(offset);
+
+ return {
+ rfqs: resultRfqs,
+ totalCount: Number(countResult?.count) || 0
+ };
+ } catch (error) {
+ console.error("Error fetching RFQs:", error);
+ return {
+ error: "Failed to fetch RFQs",
+ totalCount: 0
+ };
+ }
+ },
+ [cacheKey],
+ {
+ revalidate: 60, // 1분 캐시
+ tags: ["rfqs-query"],
+ }
+ )();
+}
+export async function getAllVendors() {
+ // Adjust the query as needed (add WHERE, ORDER, etc.)
+ const allVendors = await db.select().from(vendors)
+ return allVendors
+}
+
+
+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;
+ }
+
+ // 5. Revalidate to refresh data
+ revalidateTag("rfq-vendors");
+
+ // 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 timestamp = Date.now()
+ const originalName = file.name
+ const fileExtension = originalName.split(".").pop()
+ const fileName = `${originalName.split(".")[0]}-${timestamp}.${fileExtension}`
+
+ // 업로드 디렉토리 및 경로 정의
+ const uploadDir = join(process.cwd(), "rfq", "tbe-responses")
+
+ // 디렉토리가 없으면 생성
+ try {
+ await mkdir(uploadDir, { recursive: true })
+ } catch (error) {
+ // 이미 존재하면 무시
+ }
+
+ const filePath = join(uploadDir, fileName)
+
+ // 파일을 버퍼로 변환
+ const bytes = await file.arrayBuffer()
+ const buffer = Buffer.from(bytes)
+
+ // 파일을 서버에 저장
+ await writeFile(filePath, buffer)
+
+ // 먼저 vendorTechnicalResponses 테이블에 엔트리 생성
+ const technicalResponse = await db.insert(vendorTechnicalResponses)
+ .values({
+ responseId: vendorResponseId,
+ summary: "TBE 응답 파일 업로드", // 필요에 따라 수정
+ notes: `파일명: ${originalName}`,
+ responseStatus:"SUBMITTED"
+ })
+ .returning({ id: vendorTechnicalResponses.id });
+
+ // 생성된 기술 응답 ID 가져오기
+ const technicalResponseId = technicalResponse[0].id;
+
+ // 파일 정보를 데이터베이스에 저장
+ const dbFilePath = `/rfq/tbe-responses/${fileName}`
+
+ // vendorResponseAttachments 테이블 스키마에 맞게 데이터 삽입
+ await db.insert(vendorResponseAttachments)
+ .values({
+ // 오류 메시지를 기반으로 올바른 필드 이름 사용
+ // 테이블 스키마에 정의된 필드만 포함해야 함
+ responseId: vendorResponseId,
+ technicalResponseId: technicalResponseId,
+ // vendorId와 evaluationId 필드가 테이블에 있다면 포함, 없다면 제거
+ // vendorId: vendorId,
+ // evaluationId: evaluationId,
+ fileName: originalName,
+ filePath: dbFilePath,
+ uploadedAt: new Date(),
+ });
+
+ // 경로 재검증 (캐시된 데이터 새로고침)
+ // revalidatePath(`/rfq/${rfqId}/tbe`) // 화면 새로고침 방지를 위해 제거
+ revalidateTag(`tbe-vendor-${vendorId}`)
+ revalidateTag("all-tbe-vendors")
+
+ 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) {
+ return unstable_cache(
+ async () => {
+ // 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 }
+ },
+ [JSON.stringify(input)],
+ {
+ revalidate: 3600,
+ tags: ["tbe"],
+ }
+ )()
+}
+
+
+export async function getCBE(input: GetCBESchema, rfqId: number) {
+ return unstable_cache(
+ async () => {
+ // [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
+ };
+ },
+ // 캐싱 키 & 옵션
+ [`cbe-vendors-${rfqId}-${JSON.stringify(input)}`],
+ {
+ revalidate: 3600,
+ tags: [`cbe`, `rfq-${rfqId}`],
+ }
+ )();
+}
+
+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")
+ )
+ )
+
+ // Revalidate the tbe-vendors tag to refresh the data
+ revalidateTag("tbe-vendors")
+ revalidateTag("all-tbe-vendors")
+
+
+ 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
+
+ // 파일 저장을 위한 디렉토리 생성 (파일이 있는 경우에만)
+ let uploadDir = ""
+ if (hasFiles) {
+ uploadDir = path.join(process.cwd(), "public", "rfq", String(rfqId))
+ try {
+ await fs.mkdir(uploadDir, { recursive: true })
+ } catch (err) {
+ console.error("디렉토리 생성 실패:", err)
+ return { error: "파일 업로드를 위한 디렉토리 생성에 실패했습니다." }
+ }
+ }
+
+ // 첨부 파일 정보를 저장할 배열
+ const attachments: { filename: string; path: string }[] = []
+
+ // 파일이 있는 경우, 파일을 저장하고 첨부 파일 정보 준비
+ if (hasFiles) {
+ for (const file of files) {
+ if (file.size > 0) {
+ const originalFilename = file.name
+ const fileExtension = path.extname(originalFilename)
+ const timestamp = new Date().getTime()
+ const safeFilename = `cbe-${rfqId}-${timestamp}${fileExtension}`
+ const filePath = path.join("rfq", String(rfqId), safeFilename)
+ const fullPath = path.join(process.cwd(), "public", filePath)
+
+ try {
+ // File을 ArrayBuffer로 변환하여 파일 시스템에 저장
+ const arrayBuffer = await file.arrayBuffer()
+ const buffer = Buffer.from(arrayBuffer)
+ await fs.writeFile(fullPath, buffer)
+
+ // 첨부 파일 정보 추가
+ attachments.push({
+ filename: originalFilename,
+ path: fullPath, // 이메일 첨부를 위한 전체 경로
+ })
+ } 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`
+ }
+
+ // 각 고유 이메일 주소로 이메일 발송
+ 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: "예기치 않은 오류" })
+ }
+ }
+
+ // UI 업데이트를 위한 경로 재검증
+ revalidatePath(`/rfq/${rfqId}`)
+ revalidateTag(`cbe-vendors-${rfqId}`)
+ revalidateTag("all-cbe-vendors")
+
+ // 결과 반환
+ 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) {
+ return unstable_cache(
+ async () => {
+ // [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
+ };
+ },
+ // 캐싱 키 & 옵션
+ [`cbe-vendor-${vendorId}-${JSON.stringify(input)}`],
+ {
+ revalidate: 3600,
+ tags: [`cbe-vendor-${vendorId}`],
+ }
+ )();
+}
+
+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) {
+ return unstable_cache(
+ async () => {
+ // [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
+ };
+ },
+ // 캐싱 키 & 옵션
+ [`all-cbe-vendors-${JSON.stringify(input)}`],
+ {
+ revalidate: 3600,
+ tags: ["all-cbe-vendors"],
+ }
+ )();
+} \ No newline at end of file
diff --git a/lib/rfqs-tech/table/ItemsDialog.tsx b/lib/rfqs-tech/table/ItemsDialog.tsx
new file mode 100644
index 00000000..022d6430
--- /dev/null
+++ b/lib/rfqs-tech/table/ItemsDialog.tsx
@@ -0,0 +1,754 @@
+"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
new file mode 100644
index 00000000..acd3c34e
--- /dev/null
+++ b/lib/rfqs-tech/table/add-rfq-dialog.tsx
@@ -0,0 +1,295 @@
+"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
new file mode 100644
index 00000000..d06fae09
--- /dev/null
+++ b/lib/rfqs-tech/table/attachment-rfq-sheet.tsx
@@ -0,0 +1,426 @@
+"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
new file mode 100644
index 00000000..729bc526
--- /dev/null
+++ b/lib/rfqs-tech/table/delete-rfqs-dialog.tsx
@@ -0,0 +1,149 @@
+"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
new file mode 100644
index 00000000..81131894
--- /dev/null
+++ b/lib/rfqs-tech/table/feature-flags-provider.tsx
@@ -0,0 +1,108 @@
+"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
new file mode 100644
index 00000000..aaae6af2
--- /dev/null
+++ b/lib/rfqs-tech/table/feature-flags.tsx
@@ -0,0 +1,96 @@
+"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
new file mode 100644
index 00000000..86660dc7
--- /dev/null
+++ b/lib/rfqs-tech/table/rfqs-table-columns.tsx
@@ -0,0 +1,308 @@
+"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/${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
new file mode 100644
index 00000000..daef7e0b
--- /dev/null
+++ b/lib/rfqs-tech/table/rfqs-table-floating-bar.tsx
@@ -0,0 +1,338 @@
+"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
new file mode 100644
index 00000000..15306ecf
--- /dev/null
+++ b/lib/rfqs-tech/table/rfqs-table-toolbar-actions.tsx
@@ -0,0 +1,52 @@
+"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
new file mode 100644
index 00000000..949f49e9
--- /dev/null
+++ b/lib/rfqs-tech/table/rfqs-table.tsx
@@ -0,0 +1,254 @@
+"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
new file mode 100644
index 00000000..9517bc89
--- /dev/null
+++ b/lib/rfqs-tech/table/update-rfq-sheet.tsx
@@ -0,0 +1,243 @@
+"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
new file mode 100644
index 00000000..6efd631f
--- /dev/null
+++ b/lib/rfqs-tech/tbe-table/comments-sheet.tsx
@@ -0,0 +1,325 @@
+"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
new file mode 100644
index 00000000..81131894
--- /dev/null
+++ b/lib/rfqs-tech/tbe-table/feature-flags-provider.tsx
@@ -0,0 +1,108 @@
+"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
new file mode 100644
index 00000000..712f7ff6
--- /dev/null
+++ b/lib/rfqs-tech/tbe-table/file-dialog.tsx
@@ -0,0 +1,139 @@
+"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
new file mode 100644
index 00000000..f7aa957c
--- /dev/null
+++ b/lib/rfqs-tech/tbe-table/invite-vendors-dialog.tsx
@@ -0,0 +1,227 @@
+"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
new file mode 100644
index 00000000..6bd8a6a7
--- /dev/null
+++ b/lib/rfqs-tech/tbe-table/tbe-result-dialog.tsx
@@ -0,0 +1,208 @@
+"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
new file mode 100644
index 00000000..aecbcdb2
--- /dev/null
+++ b/lib/rfqs-tech/tbe-table/tbe-table-columns.tsx
@@ -0,0 +1,360 @@
+"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
new file mode 100644
index 00000000..f78e539c
--- /dev/null
+++ b/lib/rfqs-tech/tbe-table/tbe-table-toolbar-actions.tsx
@@ -0,0 +1,67 @@
+"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
new file mode 100644
index 00000000..a162edbb
--- /dev/null
+++ b/lib/rfqs-tech/tbe-table/tbe-table.tsx
@@ -0,0 +1,243 @@
+"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
new file mode 100644
index 00000000..3619fe77
--- /dev/null
+++ b/lib/rfqs-tech/tbe-table/vendor-contact-dialog.tsx
@@ -0,0 +1,71 @@
+"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
new file mode 100644
index 00000000..fcd0c3fb
--- /dev/null
+++ b/lib/rfqs-tech/tbe-table/vendor-contact/vendor-contact-table-column.tsx
@@ -0,0 +1,70 @@
+"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
new file mode 100644
index 00000000..c079da02
--- /dev/null
+++ b/lib/rfqs-tech/tbe-table/vendor-contact/vendor-contact-table.tsx
@@ -0,0 +1,89 @@
+'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
new file mode 100644
index 00000000..82b0934e
--- /dev/null
+++ b/lib/rfqs-tech/validations.ts
@@ -0,0 +1,284 @@
+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
new file mode 100644
index 00000000..8ec5b9f4
--- /dev/null
+++ b/lib/rfqs-tech/vendor-table/add-vendor-dialog.tsx
@@ -0,0 +1,37 @@
+"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
new file mode 100644
index 00000000..441fdcf1
--- /dev/null
+++ b/lib/rfqs-tech/vendor-table/comments-sheet.tsx
@@ -0,0 +1,318 @@
+"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
new file mode 100644
index 00000000..81131894
--- /dev/null
+++ b/lib/rfqs-tech/vendor-table/feature-flags-provider.tsx
@@ -0,0 +1,108 @@
+"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
new file mode 100644
index 00000000..8238e7b9
--- /dev/null
+++ b/lib/rfqs-tech/vendor-table/invite-vendors-dialog.tsx
@@ -0,0 +1,173 @@
+"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
new file mode 100644
index 00000000..bfcbe75b
--- /dev/null
+++ b/lib/rfqs-tech/vendor-table/vendor-list/vendor-list-table-column.tsx
@@ -0,0 +1,154 @@
+"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 {
+ setRowAction: React.Dispatch<React.SetStateAction<DataTableRowAction<VendorData> | null>>
+ setSelectedVendorIds: React.Dispatch<React.SetStateAction<number[]>> // Changed to array
+}
+
+/** getColumns: return array of ColumnDef for 'vendors' data */
+export function getColumns({
+ setRowAction,
+ 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
new file mode 100644
index 00000000..e34a5052
--- /dev/null
+++ b/lib/rfqs-tech/vendor-table/vendor-list/vendor-list-table.tsx
@@ -0,0 +1,142 @@
+"use client"
+
+import * as React from "react"
+import { ClientDataTable } from "@/components/client-data-table/data-table"
+import { DataTableRowAction, 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()
+ const [rowAction, setRowAction] =
+ React.useState<DataTableRowAction<VendorData> | null>(null)
+
+ // Changed to array for multiple selection
+ const [selectedVendorIds, setSelectedVendorIds] = React.useState<number[]>([])
+ const [isSubmitting, setIsSubmitting] = React.useState(false)
+
+ const columns = React.useMemo(
+ () => getColumns({ setRowAction, setSelectedVendorIds }),
+ [setRowAction, setSelectedVendorIds]
+ )
+
+ const [vendors, setVendors] = React.useState<VendorData[]>([])
+ const [isLoading, setIsLoading] = React.useState(false)
+
+ React.useEffect(() => {
+ async function loadAllVendors() {
+ setIsLoading(true)
+ try {
+ const allVendors = await getAllVendors()
+ setVendors(allVendors)
+ } catch (error) {
+ console.error("협력업체 목록 로드 오류:", error)
+ toast({
+ title: "Error",
+ description: "Failed to load vendors",
+ variant: "destructive",
+ })
+ } finally {
+ setIsLoading(false)
+ }
+ }
+ loadAllVendors()
+ }, [toast])
+
+ const advancedFilterFields: DataTableAdvancedFilterField<VendorData>[] = []
+
+ 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) {
+ 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={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
new file mode 100644
index 00000000..5354f93a
--- /dev/null
+++ b/lib/rfqs-tech/vendor-table/vendors-table-columns.tsx
@@ -0,0 +1,219 @@
+"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
new file mode 100644
index 00000000..9b32cf5f
--- /dev/null
+++ b/lib/rfqs-tech/vendor-table/vendors-table-floating-bar.tsx
@@ -0,0 +1,137 @@
+"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
new file mode 100644
index 00000000..864d0f4b
--- /dev/null
+++ b/lib/rfqs-tech/vendor-table/vendors-table-toolbar-actions.tsx
@@ -0,0 +1,84 @@
+"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
new file mode 100644
index 00000000..8a2c1ad9
--- /dev/null
+++ b/lib/rfqs-tech/vendor-table/vendors-table.tsx
@@ -0,0 +1,199 @@
+"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
new file mode 100644
index 00000000..2349db7e
--- /dev/null
+++ b/lib/tbe-tech/table/tbe-table-columns.tsx
@@ -0,0 +1,347 @@
+"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?.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
new file mode 100644
index 00000000..d3502032
--- /dev/null
+++ b/lib/tbe-tech/table/tbe-table-toolbar-actions.tsx
@@ -0,0 +1,68 @@
+"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
new file mode 100644
index 00000000..3d981450
--- /dev/null
+++ b/lib/tbe-tech/table/tbe-table.tsx
@@ -0,0 +1,304 @@
+"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,
+ )
+ } 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) {
+ setInitialComments([])
+ setIsLoadingComments(true)
+ const comments = rowAction?.row.original.comments
+ 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