summaryrefslogtreecommitdiff
path: root/lib/rfqs
diff options
context:
space:
mode:
authordujinkim <dujin.kim@dtsolution.co.kr>2025-03-26 00:37:41 +0000
committerdujinkim <dujin.kim@dtsolution.co.kr>2025-03-26 00:37:41 +0000
commite0dfb55c5457aec489fc084c4567e791b4c65eb1 (patch)
tree68543a65d88f5afb3a0202925804103daa91bc6f /lib/rfqs
3/25 까지의 대표님 작업사항
Diffstat (limited to 'lib/rfqs')
-rw-r--r--lib/rfqs/cbe-table/cbe-table-columns.tsx227
-rw-r--r--lib/rfqs/cbe-table/cbe-table.tsx165
-rw-r--r--lib/rfqs/cbe-table/feature-flags-provider.tsx108
-rw-r--r--lib/rfqs/repository.ts232
-rw-r--r--lib/rfqs/service.ts2810
-rw-r--r--lib/rfqs/table/BudgetaryRfqSelector.tsx261
-rw-r--r--lib/rfqs/table/ItemsDialog.tsx744
-rw-r--r--lib/rfqs/table/add-rfq-dialog.tsx349
-rw-r--r--lib/rfqs/table/attachment-rfq-sheet.tsx430
-rw-r--r--lib/rfqs/table/delete-rfqs-dialog.tsx149
-rw-r--r--lib/rfqs/table/feature-flags-provider.tsx108
-rw-r--r--lib/rfqs/table/feature-flags.tsx96
-rw-r--r--lib/rfqs/table/rfqs-table-columns.tsx315
-rw-r--r--lib/rfqs/table/rfqs-table-floating-bar.tsx338
-rw-r--r--lib/rfqs/table/rfqs-table-toolbar-actions.tsx55
-rw-r--r--lib/rfqs/table/rfqs-table.tsx264
-rw-r--r--lib/rfqs/table/update-rfq-sheet.tsx283
-rw-r--r--lib/rfqs/tbe-table/comments-sheet.tsx334
-rw-r--r--lib/rfqs/tbe-table/feature-flags-provider.tsx108
-rw-r--r--lib/rfqs/tbe-table/file-dialog.tsx141
-rw-r--r--lib/rfqs/tbe-table/invite-vendors-dialog.tsx203
-rw-r--r--lib/rfqs/tbe-table/tbe-table-columns.tsx300
-rw-r--r--lib/rfqs/tbe-table/tbe-table-toolbar-actions.tsx60
-rw-r--r--lib/rfqs/tbe-table/tbe-table.tsx190
-rw-r--r--lib/rfqs/validations.ts274
-rw-r--r--lib/rfqs/vendor-table/add-vendor-dialog.tsx37
-rw-r--r--lib/rfqs/vendor-table/comments-sheet.tsx320
-rw-r--r--lib/rfqs/vendor-table/feature-flags-provider.tsx108
-rw-r--r--lib/rfqs/vendor-table/invite-vendors-dialog.tsx177
-rw-r--r--lib/rfqs/vendor-table/vendor-list/vendor-list-table-column.tsx154
-rw-r--r--lib/rfqs/vendor-table/vendor-list/vendor-list-table.tsx142
-rw-r--r--lib/rfqs/vendor-table/vendors-table-columns.tsx276
-rw-r--r--lib/rfqs/vendor-table/vendors-table-floating-bar.tsx137
-rw-r--r--lib/rfqs/vendor-table/vendors-table-toolbar-actions.tsx84
-rw-r--r--lib/rfqs/vendor-table/vendors-table.tsx210
35 files changed, 10189 insertions, 0 deletions
diff --git a/lib/rfqs/cbe-table/cbe-table-columns.tsx b/lib/rfqs/cbe-table/cbe-table-columns.tsx
new file mode 100644
index 00000000..325b0465
--- /dev/null
+++ b/lib/rfqs/cbe-table/cbe-table-columns.tsx
@@ -0,0 +1,227 @@
+"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: (vendorId: number) => void
+ openFilesDialog: (cbeId:number , vendorId: number) => void
+}
+
+/**
+ * tanstack table 컬럼 정의 (중첩 헤더 버전)
+ */
+export function getColumns({
+ setRowAction,
+ router,
+ openCommentSheet,
+ openFilesDialog
+}: 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 === "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 === "cbeUpdated") {
+ 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,
+ })
+ }
+ })
+
+// ----------------------------------------------------------------
+// 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.cbeId ?? 0)
+ }
+
+ return (
+ <div className="flex items-center justify-center">
+ <Button
+ variant="ghost"
+ size="sm"
+ className="h-8 w-8 p-0 group relative"
+ onClick={handleClick}
+ aria-label={commCount > 0 ? `View ${commCount} comments` : "Add comment"}
+ >
+ <div className="flex items-center justify-center relative">
+ {commCount > 0 ? (
+ <>
+ <MessageSquare className="h-4 w-4 text-muted-foreground group-hover:text-primary transition-colors" />
+ <Badge
+ variant="secondary"
+ className="absolute -top-2 -right-2 h-4 min-w-4 text-xs px-1 flex items-center justify-center"
+ >
+ {commCount}
+ </Badge>
+ </>
+ ) : (
+ <MessageSquare className="h-4 w-4 text-muted-foreground group-hover:text-primary transition-colors" />
+ )}
+ </div>
+ <span className="sr-only">{commCount > 0 ? `${commCount} Comments` : "Add Comment"}</span>
+ </Button>
+ {/* <span className="ml-2 text-sm text-muted-foreground hover:text-foreground transition-colors cursor-pointer" onClick={handleClick}>
+ {commCount > 0 ? `${commCount} Comments` : "Add Comment"}
+ </span> */}
+ </div>
+ )
+ },
+ 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/cbe-table/cbe-table.tsx b/lib/rfqs/cbe-table/cbe-table.tsx
new file mode 100644
index 00000000..b2a74466
--- /dev/null
+++ b/lib/rfqs/cbe-table/cbe-table.tsx
@@ -0,0 +1,165 @@
+"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 { useFeatureFlags } from "./feature-flags-provider"
+import { Vendor, vendors } from "@/db/schema/vendors"
+import { fetchRfqAttachmentsbyCommentId, getCBE } from "../service"
+import { TbeComment } from "../tbe-table/comments-sheet"
+import { getColumns } from "./cbe-table-columns"
+import { VendorWithCbeFields } from "@/config/vendorCbeColumnsConfig"
+
+interface VendorsTableProps {
+ promises: Promise<
+ [
+ Awaited<ReturnType<typeof getCBE>>,
+ ]
+ >
+ rfqId: number
+}
+
+
+export function CbeTable({ promises, rfqId }: VendorsTableProps) {
+ const { featureFlags } = useFeatureFlags()
+
+ // Suspense로 받아온 데이터
+ const [{ data, pageCount }] = React.use(promises)
+
+ console.log(data, "data")
+
+ const [rowAction, setRowAction] = React.useState<DataTableRowAction<VendorWithCbeFields> | null>(null)
+
+ // **router** 획득
+ const router = useRouter()
+
+ const [initialComments, setInitialComments] = React.useState<TbeComment[]>([])
+ const [commentSheetOpen, setCommentSheetOpen] = React.useState(false)
+ const [selectedRfqIdForComments, setSelectedRfqIdForComments] = React.useState<number | null>(null)
+
+ const [isFileDialogOpen, setIsFileDialogOpen] = React.useState(false)
+ const [selectedVendorId, setSelectedVendorId] = React.useState<number | null>(null)
+ const [selectedTbeId, setSelectedTbeId] = React.useState<number | null>(null)
+
+ // Add handleRefresh function
+ const handleRefresh = React.useCallback(() => {
+ router.refresh();
+ }, [router]);
+
+ React.useEffect(() => {
+ if (rowAction?.type === "comments") {
+ // rowAction가 새로 세팅된 뒤 여기서 openCommentSheet 실행
+ openCommentSheet(Number(rowAction.row.original.id))
+ } else if (rowAction?.type === "files") {
+ // Handle files action
+ const vendorId = rowAction.row.original.vendorId;
+ const cbeId = rowAction.row.original.cbeId ?? 0;
+ openFilesDialog(cbeId, vendorId);
+ }
+ }, [rowAction])
+
+ async function openCommentSheet(vendorId: number) {
+ setInitialComments([])
+
+ const comments = rowAction?.row.original.comments
+
+ 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,
+ }
+ })
+ )
+ // 3) state에 저장 -> CommentSheet에서 initialComments로 사용
+ setInitialComments(commentWithAttachments)
+ }
+
+ setSelectedRfqIdForComments(vendorId)
+ setCommentSheetOpen(true)
+ }
+
+ const openFilesDialog = (cbeId: number, vendorId: number) => {
+ setSelectedTbeId(cbeId)
+ setSelectedVendorId(vendorId)
+ setIsFileDialogOpen(true)
+ }
+
+
+ // getColumns() 호출 시, router를 주입
+ const columns = React.useMemo(
+ () => getColumns({ setRowAction, router, openCommentSheet, openFilesDialog }),
+ [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: "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: ["actions"] },
+ },
+ getRowId: (originalRow) => String(originalRow.id),
+ shallow: false,
+ clearOnDefault: true,
+ })
+
+ return (
+ <>
+<div style={{ maxWidth: '80vw' }}>
+<DataTable
+ table={table}
+ // tableContainerClass="sm:max-w-[80vw] md:max-w-[80vw] lg:max-w-[80vw]"
+ // tableContainerClass="max-w-[80vw]"
+ >
+ <DataTableAdvancedToolbar
+ table={table}
+ filterFields={advancedFilterFields}
+ shallow={false}
+ >
+ {/* <VendorsTableToolbarActions table={table} rfqId={rfqId} /> */}
+ </DataTableAdvancedToolbar>
+ </DataTable>
+ </div>
+
+ </>
+ )
+} \ No newline at end of file
diff --git a/lib/rfqs/cbe-table/feature-flags-provider.tsx b/lib/rfqs/cbe-table/feature-flags-provider.tsx
new file mode 100644
index 00000000..81131894
--- /dev/null
+++ b/lib/rfqs/cbe-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/repository.ts b/lib/rfqs/repository.ts
new file mode 100644
index 00000000..ad44cf07
--- /dev/null
+++ b/lib/rfqs/repository.ts
@@ -0,0 +1,232 @@
+// src/lib/tasks/repository.ts
+import db from "@/db/db";
+import { items } from "@/db/schema/items";
+import { rfqItems, rfqs, RfqWithItems, rfqsView, type Rfq,VendorResponse, vendorResponses } from "@/db/schema/rfq";
+import { users } from "@/db/schema/users";
+import {
+ eq,
+ inArray,
+ not,
+ asc,
+ desc,
+ and,
+ ilike,
+ gte,
+ lte,
+ count,
+ gt, sql
+} from "drizzle-orm";
+import { PgTransaction } from "drizzle-orm/pg-core";
+import { RfqType } from "./validations";
+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>,
+ rfqType: RfqType = RfqType.PURCHASE
+) {
+ return tx
+ .select({
+ status: rfqs.status,
+ count: count(),
+ })
+ .from(rfqs)
+ .where(eq(rfqs.rfqType, rfqType)) // rfqType으로 필터링 추가
+ .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<RfqWithItems | null> => {
+ // 1) RFQ 단건 조회
+ const rfqsRes = await db
+ .select()
+ .from(rfqs)
+ .where(eq(rfqs.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: RfqWithItems = {
+ ...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/service.ts b/lib/rfqs/service.ts
new file mode 100644
index 00000000..6e40f0f1
--- /dev/null
+++ b/lib/rfqs/service.ts
@@ -0,0 +1,2810 @@
+// 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, GetRfqsForVendorsSchema, UpdateRfqVendorSchema, GetTBESchema, RfqType, GetCBESchema } from "./validations";
+import { asc, desc, ilike, inArray, and, gte, lte, not, 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, RfqWithItems, rfqComments, rfqEvaluations, vendorRfqView, vendorTbeView, rfqsView, vendorResponseAttachments, vendorTechnicalResponses, vendorCbeView, cbeEvaluations, vendorCommercialResponses } from "@/db/schema/rfq";
+import { countRfqs, deleteRfqById, deleteRfqsByIds, getRfqById, groupByStatus, insertRfq, insertRfqItem, selectRfqs, updateRfq, updateRfqs, updateRfqVendor } from "./repository";
+import logger from '@/lib/logger';
+import { vendorPossibleItems, vendors } from "@/db/schema/vendors";
+import { sendEmail } from "../mail/sendEmail";
+import { projects } from "@/db/schema/projects";
+import { items } from "@/db/schema/items";
+import * as z from "zod"
+import { users } from "@/db/schema/users";
+
+
+interface InviteVendorsInput {
+ rfqId: number
+ vendorIds: number[]
+ rfqType: RfqType
+}
+
+/* -----------------------------------------------------
+ 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)
+ }
+
+ let rfqTypeWhere;
+ if (input.rfqType) {
+ rfqTypeWhere = eq(rfqsView.rfqType, input.rfqType);
+ }
+
+ let whereConditions = [];
+ if (advancedWhere) whereConditions.push(advancedWhere);
+ if (globalWhere) whereConditions.push(globalWhere);
+ if (rfqTypeWhere) whereConditions.push(rfqTypeWhere);
+
+ // 조건이 있을 때만 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-${input.rfqType}`],
+ }
+ )();
+}
+
+/** Status별 개수 */
+export async function getRfqStatusCounts(rfqType: RfqType = RfqType.PURCHASE) {
+ 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) => {
+ // rfqType을 기준으로 필터링 추가
+ const rows = await groupByStatus(tx, rfqType);
+ return rows.reduce<Record<Rfq["status"], number>>((acc, { status, count }) => {
+ acc[status] = count;
+ return acc;
+ }, initial);
+ });
+
+ return result;
+ } catch (err) {
+ return {} as Record<Rfq["status"], number>;
+ }
+ },
+ [`rfq-status-counts-${rfqType}`], // 캐싱 키에 rfqType 추가
+ {
+ revalidate: 3600,
+ }
+ )();
+}
+
+
+
+/* -----------------------------------------------------
+ 2) 생성(Create)
+----------------------------------------------------- */
+
+/**
+ * Rfq 생성 후, (가장 오래된 Rfq 1개) 삭제로
+ * 전체 Rfq 개수를 고정
+ */
+export async function createRfq(input: CreateRfqSchema) {
+
+ console.log(input.createdBy, "input.createdBy")
+
+ unstable_noStore(); // Next.js 서버 액션 캐싱 방지
+ try {
+ await db.transaction(async (tx) => {
+ // 새 Rfq 생성
+ const [newTask] = await insertRfq(tx, {
+ rfqCode: input.rfqCode,
+ projectId: input.projectId || null,
+ description: input.description || null,
+ dueDate: input.dueDate,
+ status: input.status,
+ rfqType: input.rfqType, // rfqType 추가
+ createdBy: input.createdBy,
+ });
+ return newTask;
+ });
+
+ // 캐시 무효화
+ revalidateTag(`rfqs-${input.rfqType}`);
+ revalidateTag(`rfq-status-counts-${input.rfqType}`);
+
+ 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 {
+ const data = await db.transaction(async (tx) => {
+ const [res] = await updateRfq(tx, input.id, {
+ rfqCode: input.rfqCode,
+ projectId: input.projectId || null,
+ dueDate: input.dueDate,
+ status: input.status as "DRAFT" | "PUBLISHED" | "EVALUATION" | "AWARDED",
+ createdBy: input.createdBy,
+ });
+ return res;
+ });
+
+ revalidateTag("rfqs");
+ if (data.status === input.status) {
+ revalidateTag("rfqs-status-counts");
+ }
+
+
+ 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 {
+ const data = await db.transaction(async (tx) => {
+ const [res] = await updateRfqs(tx, input.ids, {
+ status: input.status,
+ dueDate: input.dueDate,
+ });
+ return res;
+ });
+
+ revalidateTag("rfqs");
+ if (data.status === input.status) {
+ revalidateTag("rfq-status-counts");
+ }
+
+
+ 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");
+ revalidateTag("rfq-status-counts");
+
+
+ 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");
+ revalidateTag("rfq-status-counts");
+
+ return { data: null, error: null };
+ } catch (err) {
+ return { data: null, error: getErrorMessage(err) };
+ }
+}
+
+// 삭제를 위한 입력 스키마
+const deleteRfqItemSchema = z.object({
+ id: z.number().int(),
+ rfqId: z.number().int(),
+ rfqType: z.nativeEnum(RfqType).default(RfqType.PURCHASE),
+});
+
+type DeleteRfqItemSchema = z.infer<typeof deleteRfqItemSchema>;
+
+/**
+ * RFQ 아이템 삭제 함수
+ */
+export async function deleteRfqItem(input: DeleteRfqItemSchema) {
+ 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("rfq-items");
+ revalidateTag(`rfqs-${input.rfqType}`);
+ 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 {
+ // 존재하지 않는 경우 새로 생성
+ const [newItem] = 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("rfq-items");
+ revalidateTag(`rfqs-${input.rfqType}`);
+ 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;
+ rfqType?: RfqType | 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) File -> Buffer
+ 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("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");
+ revalidateTag(`rfqs-${args.rfqType}`)
+
+ 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<RfqWithItems | 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 }
+ }
+
+ console.log(vendorIdList, "vendorIdList")
+
+ // ─────────────────────────────────────────────────────
+ // 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),
+ 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 [{ count }] = await tx
+ .select({ count: sql<number>`count(*)`.as("count") })
+ .from(vendorRfqView)
+ .where(finalWhere)
+
+ return [data, 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) 코멘트 조회: 기존과 동일
+ // ─────────────────────────────────────────────────────
+ const commAll = await db
+ .select()
+ .from(rfqComments)
+ .where(
+ and(
+ inArray(rfqComments.vendorId, distinctVendorIds),
+ eq(rfqComments.rfqId, rfqId)
+ )
+ )
+
+ 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: ["rfq-vendors"] }
+ )()
+}
+
+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")
+ }
+
+ // 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 baseUrl = process.env.NEXT_PUBLIC_BASE_URL || 'http://3.36.56.124:3000'
+ const loginUrl = `${baseUrl}/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("rfq-vendors")
+ revalidateTag("cbe-vendors")
+ revalidateTag("rfqs")
+ revalidateTag(`rfqs-${input.rfqType}`)
+ revalidateTag(`rfq-${rfqId}`)
+
+ // 이메일 오류가 있었는지 확인
+ 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),
+ 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
+ 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,
+ })
+ .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-vendors"],
+ }
+ )()
+}
+
+export async function getTBEforVendor(input: GetTBESchema, 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: 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
+ 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,
+
+ 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)
+
+ // 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-vendors-${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))
+
+
+ // 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, email: vendors.email })
+ .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 savePath = path.join(uploadDir, originalName)
+
+ // 파일 ArrayBuffer → Buffer 변환 후 저장
+ const arrayBuffer = await file.arrayBuffer()
+ fs.writeFile(savePath, Buffer.from(arrayBuffer))
+
+ // 저장 경로 & 파일명 기록
+ savedFiles.push({
+ fileName: originalName,
+ filePath: `/rfq/${rfqId}/${originalName}`, // public 이하 경로
+ absolutePath: savePath,
+ })
+ }
+
+ // (E) 각 벤더별로 TBE 평가 레코드, 초대 처리, 메일 발송
+ for (const v of vendorRows) {
+ if (!v.email) {
+ // 이메일 없는 경우 로직 (스킵 or throw)
+ continue
+ }
+
+ // 1) TBE 평가 레코드 생성
+ const [evalRow] = await tx
+ .insert(rfqEvaluations)
+ .values({
+ rfqId,
+ vendorId: v.id,
+ evalType: "TBE",
+ })
+ .returning({ id: rfqEvaluations.id })
+
+ // 2) rfqAttachments에 저장한 파일들을 기록
+ for (const sf of savedFiles) {
+ await tx.insert(rfqAttachments).values({
+ rfqId,
+ // vendorId: v.id,
+ evaluationId: evalRow.id,
+ fileName: sf.fileName,
+ filePath: sf.filePath,
+ })
+ }
+
+ // 4) 메일 발송
+ const baseUrl = process.env.NEXT_PUBLIC_BASE_URL || 'http://3.36.56.124:3000'
+ const loginUrl = `${baseUrl}/ko/partners/rfq`
+ await sendEmail({
+ to: v.email,
+ subject: `[RFQ ${rfqRow.rfqCode}] You are invited for TBE!`,
+ 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: savedFiles.map((sf) => ({
+ path: sf.absolutePath,
+ filename: sf.fileName,
+ })),
+ })
+ }
+
+ // 5) 캐시 무효화
+ 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("rfqs-vendor");
+ revalidateTag("rfq-vendors");
+
+ 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
+
+
+ // 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");
+
+ 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");
+ 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 []; // 오류 발생 시 빈 배열 반환
+ }
+}
+
+
+// 반환 타입 명시적 정의 - rfqCode가 null일 수 있음을 반영
+export interface BudgetaryRfq {
+ id: number;
+ rfqCode: string | null; // null 허용으로 변경
+ description: string | null;
+ projectId: number | null;
+ projectCode: string | null;
+ projectName: string | null;
+}
+
+interface GetBudgetaryRfqsParams {
+ search?: string;
+ projectId?: number;
+ limit?: number;
+ offset?: number;
+}
+
+type GetBudgetaryRfqsResponse =
+ | { rfqs: BudgetaryRfq[]; totalCount: number; error?: never }
+ | { error: string; rfqs?: never; totalCount: number }
+/**
+ * Budgetary 타입의 RFQ 목록을 가져오는 서버 액션
+ * Purchase RFQ 생성 시 부모 RFQ로 선택할 수 있도록 함
+ * 페이징 및 필터링 기능 포함
+ */
+export async function getBudgetaryRfqs(params: GetBudgetaryRfqsParams = {}): Promise<GetBudgetaryRfqsResponse> {
+ const { search, projectId, limit = 50, offset = 0 } = params;
+ const cacheKey = `budgetary-rfqs-${JSON.stringify(params)}`;
+ return unstable_cache(
+ async () => {
+ try {
+
+ const baseCondition = eq(rfqs.rfqType, RfqType.BUDGETARY);
+
+ 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(where1, where2, baseCondition)
+
+ // 총 개수 조회
+ const [countResult] = await db
+ .select({ count: count() })
+ .from(rfqs)
+ .leftJoin(projects, eq(rfqs.projectId, projects.id))
+ .where(finalWhere);
+
+ // 실제 데이터 조회
+ const budgetaryRfqs = 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: budgetaryRfqs as BudgetaryRfq[], // 타입 단언으로 호환성 보장
+ totalCount: Number(countResult?.count) || 0
+ };
+ } catch (error) {
+ console.error("Error fetching budgetary RFQs:", error);
+ return {
+ error: "Failed to fetch budgetary RFQs",
+ totalCount: 0
+ };
+ }
+ },
+ [cacheKey],
+ {
+ revalidate: 60, // 1분 캐시
+ tags: ["rfqs-budgetary"],
+ }
+ )();
+}
+
+export async function getAllVendors() {
+ // Adjust the query as needed (add WHERE, ORDER, etc.)
+ const allVendors = await db.select().from(vendors)
+ return allVendors
+}
+
+/**
+ * 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) {
+
+ console.log(evaluationId, "evaluationId")
+ 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: "템플릿 파일을 가져오는 중 오류가 발생했습니다."
+ }
+ }
+}
+
+/**
+ * 특정 TBE 템플릿 파일 다운로드를 위한 정보 조회
+ */
+export async function getTbeTemplateFileInfo(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}`,
+ })
+ .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-vendors-${vendorId}`)
+
+ 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)
+ )
+
+ // 5) rfqType 필터 추가
+ const rfqTypeFilter = input.rfqType ? eq(vendorTbeView.rfqType, input.rfqType) : undefined
+
+ // 6) finalWhere - rfqType 필터 추가
+ const finalWhere = and(
+ notRejected,
+ advancedWhere,
+ globalWhere,
+ rfqTypeFilter // 새로 추가된 rfqType 필터
+ )
+
+ // 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,
+
+ 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: ["all-tbe-vendors"],
+ }
+ )()
+}
+
+
+
+
+
+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: vendorCbeView,
+ filters: input.filters ?? [],
+ joinOperator: input.joinOperator ?? "and",
+ });
+
+ // [3] 글로벌 검색
+ let globalWhere;
+ if (input.search) {
+ const s = `%${input.search}%`;
+ globalWhere = or(
+ sql`${vendorCbeView.vendorName} ILIKE ${s}`,
+ sql`${vendorCbeView.vendorCode} ILIKE ${s}`,
+ sql`${vendorCbeView.email} ILIKE ${s}`
+ );
+ }
+
+ // [4] REJECTED 아니거나 NULL
+ const notRejected = or(
+ ne(vendorCbeView.rfqVendorStatus, "REJECTED"),
+ isNull(vendorCbeView.rfqVendorStatus)
+ );
+
+ // [5] 최종 where
+ const finalWhere = and(
+ eq(vendorCbeView.rfqId, rfqId),
+ notRejected,
+ advancedWhere,
+ globalWhere
+ );
+
+ // [6] 정렬
+ const orderBy = input.sort?.length
+ ? input.sort.map((s) => {
+ // vendor_cbe_view 컬럼 중 정렬 대상이 되는 것만 매핑
+ const col = (vendorCbeView as any)[s.id];
+ return s.desc ? desc(col) : asc(col);
+ })
+ : [asc(vendorCbeView.vendorId)];
+
+ // [7] 메인 SELECT
+ const [rows, total] = await db.transaction(async (tx) => {
+ const data = await tx
+ .select({
+ // 필요한 컬럼만 추출
+ id: vendorCbeView.vendorId,
+ cbeId: vendorCbeView.cbeId,
+ vendorId: vendorCbeView.vendorId,
+ vendorName: vendorCbeView.vendorName,
+ vendorCode: vendorCbeView.vendorCode,
+ address: vendorCbeView.address,
+ country: vendorCbeView.country,
+ email: vendorCbeView.email,
+ website: vendorCbeView.website,
+ vendorStatus: vendorCbeView.vendorStatus,
+
+ rfqId: vendorCbeView.rfqId,
+ rfqCode: vendorCbeView.rfqCode,
+ projectCode: vendorCbeView.projectCode,
+ projectName: vendorCbeView.projectName,
+ description: vendorCbeView.description,
+ dueDate: vendorCbeView.dueDate,
+
+ rfqVendorStatus: vendorCbeView.rfqVendorStatus,
+ rfqVendorUpdated: vendorCbeView.rfqVendorUpdated,
+
+ cbeResult: vendorCbeView.cbeResult,
+ cbeNote: vendorCbeView.cbeNote,
+ cbeUpdated: vendorCbeView.cbeUpdated,
+
+ // 상업평가 정보
+ totalCost: vendorCbeView.totalCost,
+ currency: vendorCbeView.currency,
+ paymentTerms: vendorCbeView.paymentTerms,
+ incoterms: vendorCbeView.incoterms,
+ deliverySchedule: vendorCbeView.deliverySchedule,
+ })
+ .from(vendorCbeView)
+ .where(finalWhere)
+ .orderBy(...orderBy)
+ .offset(offset)
+ .limit(limit);
+
+ const [{ count }] = await tx
+ .select({ count: sql<number>`count(*)`.as("count") })
+ .from(vendorCbeView)
+ .where(finalWhere);
+
+ return [data, Number(count)];
+ });
+
+ if (!rows.length) {
+ return { data: [], pageCount: 0 };
+ }
+
+ // [8] Comments 조회
+ // TBE 에서는 rfqComments + rfqEvaluations(evalType="TBE") 를 조인했지만,
+ // CBE는 cbeEvaluations 또는 evalType="CBE"를 기준으로 바꾸면 됩니다.
+ // 만약 cbeEvaluations.id 를 evaluationId 로 참조한다면 아래와 같이 innerJoin:
+ 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,
+ // cbeEvaluations에는 evalType 컬럼이 별도로 없을 수도 있음(프로젝트 구조에 맞게 수정)
+ // evalType: cbeEvaluations.evalType,
+ })
+ .from(rfqComments)
+ .innerJoin(
+ cbeEvaluations,
+ eq(cbeEvaluations.id, rfqComments.evaluationId)
+ )
+ .where(
+ and(
+ isNotNull(rfqComments.evaluationId),
+ eq(rfqComments.rfqId, rfqId),
+ inArray(rfqComments.vendorId, distinctVendorIds)
+ )
+ );
+
+ // 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] CBE 파일 조회 (프로젝트에 따라 구조가 달라질 수 있음)
+ // - TBE는 vendorTechnicalResponses 기준
+ // - CBE는 vendorCommercialResponses(가정) 등이 있을 수 있음
+ // - 여기서는 예시로 "동일한 vendorResponses + vendorResponseAttachments" 라고 가정
+ // Step 1: vendorResponses 가져오기 (rfqId + 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
+ 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: responseIds
+ const allResponseIds = responsesAll.map((r) => r.id);
+
+
+ const commercialResponsesAll = await db
+ .select({
+ id: vendorCommercialResponses.id,
+ responseId: vendorCommercialResponses.responseId,
+ })
+ .from(vendorCommercialResponses)
+ .where(inArray(vendorCommercialResponses.responseId, allResponseIds));
+
+ const commercialResponseIdsByResponseId = new Map<number, number[]>();
+ for (const cr of commercialResponsesAll) {
+ if (!commercialResponseIdsByResponseId.has(cr.responseId)) {
+ commercialResponseIdsByResponseId.set(cr.responseId, []);
+ }
+ commercialResponseIdsByResponseId.get(cr.responseId)!.push(cr.id);
+ }
+
+ const allCommercialResponseIds = commercialResponsesAll.map((cr) => cr.id);
+
+
+ // 여기서는 예시로 TBE와 마찬가지로 vendorResponseAttachments를
+ // 직접 responseId로 관리한다고 가정(혹은 commercialResponseId로 연결)
+ // Step 3: vendorResponseAttachments 조회
+ const filesAll = 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, allCommercialResponseIds),
+ isNotNull(vendorResponseAttachments.responseId)
+ )
+ );
+
+ // Step 4: responseId -> files
+ const filesByResponseId = new Map<number, any[]>();
+ for (const file of filesAll) {
+ const rid = file.responseId!;
+ if (!filesByResponseId.has(rid)) {
+ filesByResponseId.set(rid, []);
+ }
+ filesByResponseId.get(rid)!.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 5: vendorId -> files
+ const filesByVendorId = new Map<number, any[]>();
+ for (const [vendorId, responseIds] of responsesByVendorId.entries()) {
+ filesByVendorId.set(vendorId, []);
+ for (const responseId of responseIds) {
+ const files = filesByResponseId.get(responseId) || [];
+ 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: ["cbe-vendors"],
+ }
+ )();
+} \ No newline at end of file
diff --git a/lib/rfqs/table/BudgetaryRfqSelector.tsx b/lib/rfqs/table/BudgetaryRfqSelector.tsx
new file mode 100644
index 00000000..cea53c1d
--- /dev/null
+++ b/lib/rfqs/table/BudgetaryRfqSelector.tsx
@@ -0,0 +1,261 @@
+"use client"
+
+import * as React from "react"
+import { Check, ChevronsUpDown, Loader } from "lucide-react"
+import { Button } from "@/components/ui/button"
+import { Command, CommandInput, CommandList, CommandEmpty, CommandGroup, CommandItem } from "@/components/ui/command"
+import { Popover, PopoverTrigger, PopoverContent } from "@/components/ui/popover"
+import { cn } from "@/lib/utils"
+import { useDebounce } from "@/hooks/use-debounce"
+import { getBudgetaryRfqs, type BudgetaryRfq } from "../service"
+
+interface BudgetaryRfqSelectorProps {
+ selectedRfqId?: number;
+ onRfqSelect: (rfq: BudgetaryRfq | null) => void;
+ placeholder?: string;
+}
+
+export function BudgetaryRfqSelector({
+ selectedRfqId,
+ onRfqSelect,
+ placeholder = "Budgetary RFQ 선택..."
+}: BudgetaryRfqSelectorProps) {
+ const [searchTerm, setSearchTerm] = React.useState("");
+ const debouncedSearchTerm = useDebounce(searchTerm, 300);
+
+ const [open, setOpen] = React.useState(false);
+ const [loading, setLoading] = React.useState(false);
+ const [budgetaryRfqs, setBudgetaryRfqs] = React.useState<BudgetaryRfq[]>([]);
+ const [selectedRfq, setSelectedRfq] = React.useState<BudgetaryRfq | null>(null);
+ const [page, setPage] = React.useState(1);
+ const [hasMore, setHasMore] = React.useState(true);
+ const [totalCount, setTotalCount] = React.useState(0);
+
+ const listRef = React.useRef<HTMLDivElement>(null);
+
+ // 초기 선택된 RFQ가 있을 경우 로드
+ React.useEffect(() => {
+ if (selectedRfqId && open) {
+ const loadSelectedRfq = async () => {
+ try {
+ const result = await getBudgetaryRfqs({
+ limit: 1,
+ // null을 undefined로 변환하여 타입 오류 해결
+ projectId: selectedRfq?.projectId ?? undefined
+ });
+
+ if ('rfqs' in result && result.rfqs) {
+ // 옵셔널 체이닝 또는 조건부 검사로 undefined 체크
+ const foundRfq = result.rfqs.find(rfq => rfq.id === selectedRfqId);
+ if (foundRfq) {
+ setSelectedRfq(foundRfq);
+ }
+ }
+ } catch (error) {
+ console.error("선택된 RFQ 로드 오류:", error);
+ }
+ };
+
+ if (!selectedRfq || selectedRfq.id !== selectedRfqId) {
+ loadSelectedRfq();
+ }
+ }
+ }, [selectedRfqId, open, selectedRfq]);
+
+ // 검색어 변경 시 데이터 리셋 및 재로드
+ React.useEffect(() => {
+ if (open) {
+ setPage(1);
+ setHasMore(true);
+ setBudgetaryRfqs([]);
+ loadBudgetaryRfqs(1, true);
+ }
+ }, [debouncedSearchTerm, open]);
+
+ // 데이터 로드 함수
+ const loadBudgetaryRfqs = async (pageToLoad: number, reset = false) => {
+ if (!open) return;
+
+ setLoading(true);
+ try {
+ const limit = 20; // 한 번에 로드할 항목 수
+ const result = await getBudgetaryRfqs({
+ search: debouncedSearchTerm,
+ limit,
+ offset: (pageToLoad - 1) * limit,
+ });
+
+ if ('rfqs' in result && result.rfqs) {
+ if (reset) {
+ setBudgetaryRfqs(result.rfqs);
+ } else {
+ setBudgetaryRfqs(prev => [...prev, ...result.rfqs]);
+ }
+
+ setTotalCount(result.totalCount);
+ setHasMore(result.rfqs.length === limit && (pageToLoad * limit) < result.totalCount);
+ setPage(pageToLoad);
+ }
+ } catch (error) {
+ console.error("Budgetary RFQs 로드 오류:", error);
+ } finally {
+ setLoading(false);
+ }
+ };
+
+ // 무한 스크롤 처리
+ const handleScroll = () => {
+ if (listRef.current) {
+ const { scrollTop, scrollHeight, clientHeight } = listRef.current;
+
+ // 스크롤이 90% 이상 내려갔을 때 다음 페이지 로드
+ if (scrollTop + clientHeight >= scrollHeight * 0.9 && !loading && hasMore) {
+ loadBudgetaryRfqs(page + 1);
+ }
+ }
+ };
+
+ // RFQ를 프로젝트별로 그룹화하는 함수
+ const groupRfqsByProject = (rfqs: BudgetaryRfq[]) => {
+ const groups: Record<string, {
+ projectId: number | null;
+ projectCode: string | null;
+ projectName: string | null;
+ rfqs: BudgetaryRfq[];
+ }> = {};
+
+ // 'No Project' 그룹 기본 생성
+ groups['no-project'] = {
+ projectId: null,
+ projectCode: null,
+ projectName: null,
+ rfqs: []
+ };
+
+ // 프로젝트별로 RFQ 그룹화
+ rfqs.forEach(rfq => {
+ const key = rfq.projectId ? `project-${rfq.projectId}` : 'no-project';
+
+ if (!groups[key] && rfq.projectId) {
+ groups[key] = {
+ projectId: rfq.projectId,
+ projectCode: rfq.projectCode,
+ projectName: rfq.projectName,
+ rfqs: []
+ };
+ }
+
+ groups[key].rfqs.push(rfq);
+ });
+
+ // 필터링된 결과가 있는 그룹만 남기기
+ return Object.values(groups).filter(group => group.rfqs.length > 0);
+ };
+
+ // 그룹화된 RFQ 목록
+ const groupedRfqs = React.useMemo(() => {
+ return groupRfqsByProject(budgetaryRfqs);
+ }, [budgetaryRfqs]);
+
+ // RFQ 선택 처리
+ const handleRfqSelect = (rfq: BudgetaryRfq | null) => {
+ setSelectedRfq(rfq);
+ onRfqSelect(rfq);
+ setOpen(false);
+ };
+
+ return (
+ <Popover open={open} onOpenChange={setOpen}>
+ <PopoverTrigger asChild>
+ <Button
+ variant="outline"
+ role="combobox"
+ aria-expanded={open}
+ className="w-full justify-between"
+ >
+ {selectedRfq
+ ? `${selectedRfq.rfqCode || ""} - ${selectedRfq.description || ""}`
+ : placeholder}
+ <ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
+ </Button>
+ </PopoverTrigger>
+ <PopoverContent className="w-[400px] p-0">
+ <Command>
+ <CommandInput
+ placeholder="Budgetary RFQ 코드/설명/프로젝트 검색..."
+ value={searchTerm}
+ onValueChange={setSearchTerm}
+ />
+ <CommandList
+ className="max-h-[300px]"
+ ref={listRef}
+ onScroll={handleScroll}
+ >
+ <CommandEmpty>검색 결과가 없습니다</CommandEmpty>
+
+ <CommandGroup>
+ <CommandItem
+ value="none"
+ onSelect={() => handleRfqSelect(null)}
+ >
+ <Check
+ className={cn(
+ "mr-2 h-4 w-4",
+ !selectedRfq
+ ? "opacity-100"
+ : "opacity-0"
+ )}
+ />
+ <span className="font-medium">선택 안함</span>
+ </CommandItem>
+ </CommandGroup>
+
+ {groupedRfqs.map((group, index) => (
+ <CommandGroup
+ key={`group-${group.projectId || index}`}
+ heading={
+ group.projectId
+ ? `${group.projectCode || ""} - ${group.projectName || ""}`
+ : "프로젝트 없음"
+ }
+ >
+ {group.rfqs.map((rfq) => (
+ <CommandItem
+ key={rfq.id}
+ value={`${rfq.rfqCode || ""} ${rfq.description || ""}`}
+ onSelect={() => handleRfqSelect(rfq)}
+ >
+ <Check
+ className={cn(
+ "mr-2 h-4 w-4",
+ selectedRfq?.id === rfq.id
+ ? "opacity-100"
+ : "opacity-0"
+ )}
+ />
+ <span className="font-medium">{rfq.rfqCode || ""}</span>
+ <span className="ml-2 text-gray-500 truncate">
+ - {rfq.description || ""}
+ </span>
+ </CommandItem>
+ ))}
+ </CommandGroup>
+ ))}
+
+ {loading && (
+ <div className="py-2 text-center">
+ <Loader className="h-4 w-4 animate-spin mx-auto" />
+ </div>
+ )}
+
+ {!loading && !hasMore && budgetaryRfqs.length > 0 && (
+ <div className="py-2 text-center text-sm text-muted-foreground">
+ 총 {totalCount}개 중 {budgetaryRfqs.length}개 표시됨
+ </div>
+ )}
+ </CommandList>
+ </Command>
+ </PopoverContent>
+ </Popover>
+ );
+} \ No newline at end of file
diff --git a/lib/rfqs/table/ItemsDialog.tsx b/lib/rfqs/table/ItemsDialog.tsx
new file mode 100644
index 00000000..f1dbf90e
--- /dev/null
+++ b/lib/rfqs/table/ItemsDialog.tsx
@@ -0,0 +1,744 @@
+"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"
+import { RfqType } from "../validations"
+
+// 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; name: string }[];
+ rfqType?: RfqType;
+}
+
+export function RfqsItemsDialog({
+ open,
+ onOpenChange,
+ rfq,
+ defaultItems = [],
+ itemsList,
+ rfqType
+}: RfqsItemsDialogProps) {
+ const rfqId = rfq?.rfqId ?? 0;
+
+ // 편집 가능 여부 확인 - 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,
+ rfqType: rfqType ?? RfqType.PURCHASE
+ })
+ );
+
+ // 2. 생성/수정 처리 - 폼에 남아있는 아이템들
+ const upsertPromises = data.items.map((item) =>
+ createRfqItem({
+ rfqId: rfqId,
+ itemCode: item.itemCode,
+ description: item.description,
+ // 명시적으로 숫자로 변환
+ quantity: Number(item.quantity),
+ uom: item.uom,
+ rfqType: rfqType ?? RfqType.PURCHASE,
+ 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>
+ {rfqType && (
+ <Badge variant={rfqType === RfqType.PURCHASE ? "default" : "secondary"} className="ml-1">
+ {rfqType === RfqType.PURCHASE ? "구매 RFQ" : "예산 RFQ"}
+ </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);
+ })
+ .map((it) => ({
+ code: it.code ?? "", // fallback
+ name: it.name,
+ }));
+
+ // 선택된 아이템 찾기
+ 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]">
+ <FormControl>
+ <Popover open={popoverOpen} onOpenChange={setPopoverOpen}>
+ <PopoverTrigger asChild>
+ <Button
+ // 컴포넌트에 ref 전달
+ ref={el => {
+ inputRefs.current[index] = el;
+ }}
+ variant="outline"
+ role="combobox"
+ aria-expanded={popoverOpen}
+ className="w-full justify-between"
+ data-error={!!form.formState.errors.items?.[index]?.itemCode}
+ data-state={selected ? "filled" : "empty"}
+ >
+ {selected ? `${selected.code} - ${selected.name}` : "아이템 선택..."}
+ <ChevronsUpDown className="ml-2 h-4 w-4 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) => {
+ const label = `${it.code} - ${it.name}`;
+ return (
+ <CommandItem
+ key={it.code}
+ value={label}
+ onSelect={() => {
+ field.onChange(it.code);
+ setPopoverOpen(false);
+ // 자동으로 다음 필드로 포커스 이동
+ focusField(`input[name="items.${index}.description"]`);
+ }}
+ >
+ {label}
+ <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} - ${selected.name}` : 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/table/add-rfq-dialog.tsx b/lib/rfqs/table/add-rfq-dialog.tsx
new file mode 100644
index 00000000..1d824bc0
--- /dev/null
+++ b/lib/rfqs/table/add-rfq-dialog.tsx
@@ -0,0 +1,349 @@
+"use client"
+
+import * as React from "react"
+import { useForm } from "react-hook-form"
+import { zodResolver } from "@hookform/resolvers/zod"
+import { Check, ChevronsUpDown } from "lucide-react"
+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 {
+ Select,
+ SelectContent,
+ SelectGroup,
+ SelectItem,
+ SelectTrigger,
+ SelectValue,
+} from "@/components/ui/select"
+import { Popover, PopoverTrigger, PopoverContent } from "@/components/ui/popover"
+import { Command, CommandInput, CommandList, CommandEmpty, CommandGroup, CommandItem } from "@/components/ui/command"
+
+import { useSession } from "next-auth/react"
+import { createRfqSchema, type CreateRfqSchema, RfqType } from "../validations"
+import { createRfq, getBudgetaryRfqs } from "../service"
+import { ProjectSelector } from "@/components/ProjectSelector"
+import { type Project } from "../service"
+import { cn } from "@/lib/utils"
+import { BudgetaryRfqSelector } from "./BudgetaryRfqSelector"
+import { type BudgetaryRfq as ServiceBudgetaryRfq } from "../service";
+
+// 부모 RFQ 정보 타입 정의
+interface BudgetaryRfq {
+ id: number;
+ rfqCode: string;
+ description: string | null;
+}
+
+interface AddRfqDialogProps {
+ rfqType?: RfqType;
+}
+
+export function AddRfqDialog({ rfqType = RfqType.PURCHASE }: AddRfqDialogProps) {
+ const [open, setOpen] = React.useState(false)
+ const { data: session, status } = useSession()
+ const [budgetaryRfqs, setBudgetaryRfqs] = React.useState<BudgetaryRfq[]>([])
+ const [isLoadingBudgetary, setIsLoadingBudgetary] = React.useState(false)
+ const [budgetarySearchOpen, setBudgetarySearchOpen] = React.useState(false)
+ const [budgetarySearchTerm, setBudgetarySearchTerm] = React.useState("")
+ const [selectedBudgetaryRfq, setSelectedBudgetaryRfq] = React.useState<BudgetaryRfq | null>(null)
+
+ // 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;
+
+ // Debug logging - remove in production
+ console.log("Session status:", status);
+ console.log("Session data:", session);
+ console.log("User ID:", id);
+
+ return id;
+ }, [session, status]);
+
+ // RfqType에 따른 타이틀 생성
+ const getTitle = () => {
+ return rfqType === RfqType.PURCHASE
+ ? "Purchase RFQ"
+ : "Budgetary RFQ";
+ };
+
+ // RHF + Zod
+ const form = useForm<CreateRfqSchema>({
+ resolver: zodResolver(createRfqSchema),
+ defaultValues: {
+ rfqCode: "",
+ description: "",
+ projectId: undefined,
+ parentRfqId: undefined,
+ dueDate: new Date(),
+ status: "DRAFT",
+ rfqType: rfqType,
+ // 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]);
+
+ // Budgetary RFQ 목록 로드 (Purchase RFQ 생성 시만)
+ React.useEffect(() => {
+ if (rfqType === RfqType.PURCHASE && open) {
+ const loadBudgetaryRfqs = async () => {
+ setIsLoadingBudgetary(true);
+ try {
+ const result = await getBudgetaryRfqs();
+ if ('rfqs' in result) {
+ setBudgetaryRfqs(result.rfqs as unknown as BudgetaryRfq[]);
+ } else if ('error' in result) {
+ console.error("Budgetary RFQs 로드 오류:", result.error);
+ }
+ } catch (error) {
+ console.error("Budgetary RFQs 로드 오류:", error);
+ } finally {
+ setIsLoadingBudgetary(false);
+ }
+ };
+
+ loadBudgetaryRfqs();
+ }
+ }, [rfqType, open]);
+
+ // 검색어로 필터링된 Budgetary RFQ 목록
+ const filteredBudgetaryRfqs = React.useMemo(() => {
+ if (!budgetarySearchTerm.trim()) return budgetaryRfqs;
+
+ const lowerSearch = budgetarySearchTerm.toLowerCase();
+ return budgetaryRfqs.filter(
+ rfq =>
+ rfq.rfqCode.toLowerCase().includes(lowerSearch) ||
+ (rfq.description && rfq.description.toLowerCase().includes(lowerSearch))
+ );
+ }, [budgetaryRfqs, budgetarySearchTerm]);
+
+ // 프로젝트 선택 처리
+ const handleProjectSelect = (project: Project) => {
+ form.setValue("projectId", project.id);
+ };
+
+ // Budgetary RFQ 선택 처리
+ const handleBudgetaryRfqSelect = (rfq: BudgetaryRfq) => {
+ setSelectedBudgetaryRfq(rfq);
+ form.setValue("parentRfqId", rfq.id);
+ setBudgetarySearchOpen(false);
+ };
+
+ 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
+ };
+
+ console.log("Submitting form data:", submitData);
+
+ const result = await createRfq(submitData);
+ if (result.error) {
+ toast.error(`에러: ${result.error}`);
+ return;
+ }
+
+ toast.success("RFQ가 성공적으로 생성되었습니다.");
+ form.reset();
+ setSelectedBudgetaryRfq(null);
+ setOpen(false);
+ }
+
+ function handleDialogOpenChange(nextOpen: boolean) {
+ if (!nextOpen) {
+ form.reset();
+ setSelectedBudgetaryRfq(null);
+ }
+ 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 {getTitle()}
+ </Button>
+ </DialogTrigger>
+
+ <DialogContent>
+ <DialogHeader>
+ <DialogTitle>Create New {getTitle()}</DialogTitle>
+ <DialogDescription>
+ 새 {getTitle()} 정보를 입력하고 <b>Create</b> 버튼을 누르세요.
+ </DialogDescription>
+ </DialogHeader>
+
+ <Form {...form}>
+ <form onSubmit={form.handleSubmit(onSubmit)}>
+ <div className="space-y-4 py-4">
+ {/* rfqType - hidden field */}
+ <FormField
+ control={form.control}
+ name="rfqType"
+ 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>
+ )}
+ />
+
+ {/* Budgetary RFQ Selector - 구매용 RFQ 생성 시에만 표시 */}
+ {rfqType === RfqType.PURCHASE && (
+ <FormField
+ control={form.control}
+ name="parentRfqId"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>Budgetary RFQ (Optional)</FormLabel>
+ <FormControl>
+ <BudgetaryRfqSelector
+ selectedRfqId={field.value as number | undefined}
+ onRfqSelect={(rfq) => {
+ setSelectedBudgetaryRfq(rfq as any);
+ form.setValue("parentRfqId", rfq?.id);
+ }}
+ placeholder="Budgetary RFQ 선택..."
+ />
+ </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>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) {
+ field.onChange(new Date(val + "T00:00:00"))
+ }
+ }}
+ />
+ </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/table/attachment-rfq-sheet.tsx b/lib/rfqs/table/attachment-rfq-sheet.tsx
new file mode 100644
index 00000000..57a170e1
--- /dev/null
+++ b/lib/rfqs/table/attachment-rfq-sheet.tsx
@@ -0,0 +1,430 @@
+"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 { Trash2, Plus, 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 { RfqType } from "../validations"
+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[]
+ rfqType?: RfqType
+ rfq: RfqWithItemCount | null
+ /** 업로드/삭제 후 상위 테이블에 itemCount 등을 업데이트하기 위한 콜백 */
+ onAttachmentsUpdated?: (rfqId: number, newItemCount: number) => void
+}
+
+/**
+ * RfqAttachmentsSheet:
+ * - 기존 첨부 목록 (다운로드 + 삭제)
+ * - 새 파일 Dropzone
+ * - Save 시 processRfqAttachments(server action)
+ */
+export function RfqAttachmentsSheet({
+ defaultAttachments = [],
+ onAttachmentsUpdated,
+ rfq,
+ rfqType,
+ ...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
+ rfqType
+ })
+
+ 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/table/delete-rfqs-dialog.tsx b/lib/rfqs/table/delete-rfqs-dialog.tsx
new file mode 100644
index 00000000..09596bc7
--- /dev/null
+++ b/lib/rfqs/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 { Rfq, 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/table/feature-flags-provider.tsx b/lib/rfqs/table/feature-flags-provider.tsx
new file mode 100644
index 00000000..81131894
--- /dev/null
+++ b/lib/rfqs/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/table/feature-flags.tsx b/lib/rfqs/table/feature-flags.tsx
new file mode 100644
index 00000000..aaae6af2
--- /dev/null
+++ b/lib/rfqs/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/table/rfqs-table-columns.tsx b/lib/rfqs/table/rfqs-table-columns.tsx
new file mode 100644
index 00000000..98df3bc8
--- /dev/null
+++ b/lib/rfqs/table/rfqs-table-columns.tsx
@@ -0,0 +1,315 @@
+"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"
+import { RfqType } from "../validations"
+
+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
+ rfqType?: RfqType
+}
+
+/**
+ * tanstack table 컬럼 정의 (중첩 헤더 버전)
+ */
+export function getColumns({
+ setRowAction,
+ openItemsModal,
+ openAttachmentsSheet,
+ router,
+ rfqType,
+}: 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 }) {
+ const [isUpdatePending, startUpdateTransition] = React.useTransition()
+
+ // 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(
+ rfqType === RfqType.PURCHASE
+ ? `/evcp/rfq/${rfq.rfqId}`
+ : `/evcp/budgetary/${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/table/rfqs-table-floating-bar.tsx b/lib/rfqs/table/rfqs-table-floating-bar.tsx
new file mode 100644
index 00000000..daef7e0b
--- /dev/null
+++ b/lib/rfqs/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/table/rfqs-table-toolbar-actions.tsx b/lib/rfqs/table/rfqs-table-toolbar-actions.tsx
new file mode 100644
index 00000000..6402e625
--- /dev/null
+++ b/lib/rfqs/table/rfqs-table-toolbar-actions.tsx
@@ -0,0 +1,55 @@
+"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 { RfqWithItemCount } from "@/db/schema/rfq"
+import { DeleteRfqsDialog } from "./delete-rfqs-dialog"
+import { AddRfqDialog } from "./add-rfq-dialog"
+import { RfqType } from "../validations"
+
+
+interface RfqsTableToolbarActionsProps {
+ table: Table<RfqWithItemCount>
+ rfqType?: RfqType;
+}
+
+export function RfqsTableToolbarActions({ table , rfqType = RfqType.PURCHASE}: 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 rfqType={rfqType} />
+
+
+ {/** 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/table/rfqs-table.tsx b/lib/rfqs/table/rfqs-table.tsx
new file mode 100644
index 00000000..7d996816
--- /dev/null
+++ b/lib/rfqs/table/rfqs-table.tsx
@@ -0,0 +1,264 @@
+"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 { RfqItem, RfqWithItemCount, rfqs } from "@/db/schema/rfq"
+import { RfqsTableFloatingBar } from "./rfqs-table-floating-bar"
+import { UpdateRfqSheet } from "./update-rfq-sheet"
+import { DeleteRfqsDialog } from "./delete-rfqs-dialog"
+import { RfqsTableToolbarActions } from "./rfqs-table-toolbar-actions"
+import { RfqsItemsDialog } from "./ItemsDialog"
+import { getAllItems } from "@/lib/items/service"
+import { RfqAttachmentsSheet } from "./attachment-rfq-sheet"
+import { useRouter } from "next/navigation"
+import { RfqType } from "../validations"
+
+interface RfqsTableProps {
+ promises: Promise<
+ [
+ Awaited<ReturnType<typeof getRfqs>>,
+ Awaited<ReturnType<typeof getRfqStatusCounts>>,
+ Awaited<ReturnType<typeof getAllItems>>,
+ ]
+ >;
+ rfqType?: RfqType; // rfqType props 추가
+}
+
+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, rfqType = RfqType.PURCHASE }: RfqsTableProps) {
+ const { featureFlags } = useFeatureFlags()
+
+ 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 ?? "",
+ name: v.itemName ?? "",
+ }));
+
+ 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]);
+
+ // rfqType에 따른 제목 계산
+ const getRfqTypeTitle = () => {
+ return rfqType === RfqType.PURCHASE ? "Purchase RFQ" : "Budgetary RFQ";
+ };
+
+ 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,
+ rfqType
+ }), [setRowAction, router, rfqType]);
+
+ /**
+ * 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: '80vw' }}>
+ <DataTable
+ table={table}
+ floatingBar={<RfqsTableFloatingBar table={table} />}
+ >
+ <DataTableAdvancedToolbar
+ table={table}
+ filterFields={advancedFilterFields}
+ shallow={false}
+ >
+ <RfqsTableToolbarActions table={table} rfqType={rfqType} />
+ </DataTableAdvancedToolbar>
+ </DataTable>
+
+ <UpdateRfqSheet
+ open={rowAction?.type === "update"}
+ onOpenChange={() => setRowAction(null)}
+ rfq={rowAction?.row.original ?? null}
+ rfqType={rfqType}
+ />
+
+ <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}
+ rfqType={rfqType}
+ />
+
+ <RfqAttachmentsSheet
+ open={attachmentsOpen}
+ onOpenChange={setAttachmentsOpen}
+ defaultAttachments={attachDefault}
+ rfqType={rfqType}
+ rfq={selectedRfq ?? null}
+ onAttachmentsUpdated={handleAttachmentsUpdated}
+ />
+ </div>
+ )
+} \ No newline at end of file
diff --git a/lib/rfqs/table/update-rfq-sheet.tsx b/lib/rfqs/table/update-rfq-sheet.tsx
new file mode 100644
index 00000000..769f25e7
--- /dev/null
+++ b/lib/rfqs/table/update-rfq-sheet.tsx
@@ -0,0 +1,283 @@
+"use client"
+
+import * as React from "react"
+import { zodResolver } from "@hookform/resolvers/zod"
+import { Loader } from "lucide-react"
+import { useForm } from "react-hook-form"
+import { toast } from "sonner"
+import { useSession } from "next-auth/react"
+
+import { Button } from "@/components/ui/button"
+import {
+ Form,
+ FormControl,
+ FormField,
+ FormItem,
+ FormLabel,
+ FormMessage,
+} from "@/components/ui/form"
+import {
+ Select,
+ SelectContent,
+ SelectGroup,
+ SelectItem,
+ SelectTrigger,
+ SelectValue,
+} from "@/components/ui/select"
+import {
+ Sheet,
+ SheetClose,
+ SheetContent,
+ SheetDescription,
+ SheetFooter,
+ SheetHeader,
+ SheetTitle,
+} from "@/components/ui/sheet"
+import { Input } from "@/components/ui/input"
+
+import { Rfq, RfqWithItemCount } from "@/db/schema/rfq"
+import { RfqType, updateRfqSchema, type UpdateRfqSchema } from "../validations"
+import { modifyRfq } from "../service"
+import { ProjectSelector } from "@/components/ProjectSelector"
+import { type Project } from "../service"
+import { BudgetaryRfqSelector } from "./BudgetaryRfqSelector"
+
+interface UpdateRfqSheetProps
+ extends React.ComponentPropsWithRef<typeof Sheet> {
+ rfq: RfqWithItemCount | null
+ rfqType?: RfqType;
+}
+
+
+interface BudgetaryRfq {
+ id: number;
+ rfqCode: string;
+ description: string | null;
+}
+
+
+export function UpdateRfqSheet({ rfq,rfqType = RfqType.PURCHASE , ...props }: UpdateRfqSheetProps) {
+ const [isUpdatePending, startUpdateTransition] = React.useTransition()
+ const { data: session } = useSession()
+ const userId = Number(session?.user?.id || 1)
+ const [selectedBudgetaryRfq, setSelectedBudgetaryRfq] = React.useState<BudgetaryRfq | null>(null)
+
+ // 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) => {
+ form.setValue("projectId", project.id);
+ };
+
+ async function onSubmit(input: UpdateRfqSchema) {
+ startUpdateTransition(async () => {
+ if (!rfq) return
+
+ 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>
+ )}
+ />
+
+ {/* Budgetary RFQ Selector - 구매용 RFQ 생성 시에만 표시 */}
+ {rfqType === RfqType.PURCHASE && (
+ <FormField
+ control={form.control}
+ name="parentRfqId"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>Budgetary RFQ (Optional)</FormLabel>
+ <FormControl>
+ <BudgetaryRfqSelector
+ selectedRfqId={field.value as number | undefined}
+ onRfqSelect={(rfq) => {
+ setSelectedBudgetaryRfq(rfq as any);
+ form.setValue("parentRfqId", rfq?.id);
+ }}
+ placeholder="Budgetary RFQ 선택..."
+ />
+ </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 (type="date") */}
+ <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>
+ <SelectGroup>
+ {["DRAFT", "PUBLISHED", "EVALUATION", "AWARDED"].map((item) => (
+ <SelectItem key={item} value={item} className="capitalize">
+ {item}
+ </SelectItem>
+ ))}
+ </SelectGroup>
+ </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 disabled={isUpdatePending}>
+ {isUpdatePending && (
+ <Loader className="mr-2 size-4 animate-spin" aria-hidden="true" />
+ )}
+ Save
+ </Button>
+ </SheetFooter>
+ </form>
+ </Form>
+ </SheetContent>
+ </Sheet>
+ )
+} \ No newline at end of file
diff --git a/lib/rfqs/tbe-table/comments-sheet.tsx b/lib/rfqs/tbe-table/comments-sheet.tsx
new file mode 100644
index 00000000..bea1fc8e
--- /dev/null
+++ b/lib/rfqs/tbe-table/comments-sheet.tsx
@@ -0,0 +1,334 @@
+"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 { Loader, Download, X } 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"
+
+// DB 스키마에서 필요한 타입들을 가져온다고 가정
+// (실제 프로젝트에 맞춰 import를 수정하세요.)
+import { RfqWithAll } from "@/db/schema/rfq"
+import { createRfqCommentWithAttachments } from "../service"
+import { formatDate } from "@/lib/utils"
+
+// 코멘트 + 첨부파일 구조 (단순 예시)
+// 실제 DB 스키마에 맞춰 조정
+export interface TbeComment {
+ id: number
+ commentText: string
+ commentedBy?: number
+ createdAt?: string | Date
+ attachments?: {
+ id: number
+ fileName: string
+ filePath: string
+ }[]
+}
+
+interface CommentSheetProps extends React.ComponentPropsWithRef<typeof Sheet> {
+ /** 코멘트를 작성할 RFQ 정보 */
+ /** 이미 존재하는 모든 코멘트 목록 (서버에서 불러와 주입) */
+ initialComments?: TbeComment[]
+
+ /** 사용자(작성자) ID (로그인 세션 등에서 가져옴) */
+ currentUserId: number
+ rfqId:number
+ vendorId:number
+ /** 댓글 저장 후 갱신용 콜백 (옵션) */
+ onCommentsUpdated?: (comments: TbeComment[]) => void
+}
+
+// 새 코멘트 작성 폼 스키마
+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,
+ ...props
+}: CommentSheetProps) {
+ const [comments, setComments] = React.useState<TbeComment[]>(initialComments)
+ const [isPending, startTransition] = React.useTransition()
+
+ React.useEffect(() => {
+ setComments(initialComments)
+ }, [initialComments])
+
+
+ // RHF 세팅
+ const form = useForm<CommentFormValues>({
+ resolver: zodResolver(commentFormSchema),
+ defaultValues: {
+ commentText: "",
+ newFiles: []
+ }
+ })
+
+ // formFieldArray 예시 (파일 목록)
+ const { fields: newFileFields, append, remove } = useFieldArray({
+ control: form.control,
+ name: "newFiles"
+ })
+
+ // 1) 기존 코멘트 + 첨부 보여주기
+ // 간단히 테이블 하나로 표현
+ // 실제로는 Bubble 형태의 UI, Accordion, Timeline 등 다양하게 구성할 수 있음
+ function renderExistingComments() {
+ 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 || c.attachments.length === 0) && (
+ <span className="text-sm text-muted-foreground">No files</span>
+ )}
+ {c.attachments && c.attachments.length > 0 && (
+ <div className="flex flex-col gap-1">
+ {c.attachments.map((att) => (
+ <div key={att.id} className="flex items-center gap-2">
+ <a
+ href={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.commentedBy ?? "-"}
+ </TableCell>
+ </TableRow>
+ ))}
+ </TableBody>
+ </Table>
+ )
+ }
+
+ // 2) 새 파일 Drop
+ function handleDropAccepted(files: File[]) {
+ // 드롭된 File[]을 RHF field array에 추가
+ const toAppend = files.map((f) => f)
+ append(toAppend)
+ }
+
+
+ // 3) 저장(Submit)
+ async function onSubmit(data: CommentFormValues) {
+
+ if (!rfqId) return
+ startTransition(async () => {
+ try {
+ // 서버 액션 호출
+ const res = await createRfqCommentWithAttachments({
+ rfqId: rfqId,
+ vendorId: vendorId, // 필요시 세팅
+ commentText: data.commentText,
+ commentedBy: currentUserId,
+ evaluationId: null, // 필요시 세팅
+ files: data.newFiles
+ })
+
+ if (!res.ok) {
+ throw new Error("Failed to create comment")
+ }
+
+ toast.success("Comment created")
+
+ // 새 코멘트를 다시 불러오거나,
+ // 여기서는 임시로 "새로운 코멘트가 추가됐다" 라고 가정하여 클라이언트에서 상태 업데이트
+ const newComment: TbeComment = {
+ id: res.commentId, // 서버에서 반환된 commentId
+ commentText: data.commentText,
+ commentedBy: currentUserId,
+ createdAt: new Date().toISOString(),
+ attachments: (data.newFiles?.map((f, idx) => ({
+ id: Math.random() * 100000,
+ 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}>
+ <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 (파일 첨부) */}
+ <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 && <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/tbe-table/feature-flags-provider.tsx b/lib/rfqs/tbe-table/feature-flags-provider.tsx
new file mode 100644
index 00000000..81131894
--- /dev/null
+++ b/lib/rfqs/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/tbe-table/file-dialog.tsx b/lib/rfqs/tbe-table/file-dialog.tsx
new file mode 100644
index 00000000..772eb930
--- /dev/null
+++ b/lib/rfqs/tbe-table/file-dialog.tsx
@@ -0,0 +1,141 @@
+"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">
+ <Button variant="ghost" size="icon" onClick={() => downloadSubmittedFile(file)}>
+ <Download className="h-4 w-4" />
+ <span className="sr-only">파일 다운로드</span>
+ </Button>
+ </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/tbe-table/invite-vendors-dialog.tsx b/lib/rfqs/tbe-table/invite-vendors-dialog.tsx
new file mode 100644
index 00000000..e38e0ede
--- /dev/null
+++ b/lib/rfqs/tbe-table/invite-vendors-dialog.tsx
@@ -0,0 +1,203 @@
+"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"
+
+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))
+ 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="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" />
+ 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>
+
+ {/* 파일 첨부 */}
+ {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>
+ <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"}.
+ </DrawerDescription>
+ </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/tbe-table/tbe-table-columns.tsx b/lib/rfqs/tbe-table/tbe-table-columns.tsx
new file mode 100644
index 00000000..0e9b7064
--- /dev/null
+++ b/lib/rfqs/tbe-table/tbe-table-columns.tsx
@@ -0,0 +1,300 @@
+"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 {
+ VendorTbeColumnConfig,
+ 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
+}
+
+/**
+ * tanstack table 컬럼 정의 (중첩 헤더 버전)
+ */
+export function getColumns({
+ setRowAction,
+ router,
+ openCommentSheet,
+ openFilesDialog
+}: 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 === "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
+}
+
+ // ----------------------------------------------------------------
+ // 4) Actions 컬럼 (예: 초대하기 버튼)
+ // ----------------------------------------------------------------
+ // const actionsColumn: ColumnDef<VendorWithTbeFields> = {
+ // id: "actions",
+ // cell: ({ row }) => {
+ // const status = row.original.tbeResult
+ // // 예: 만약 tbeResult가 없을 때만 초대하기 버튼 표시
+ // if (status) {
+ // return null
+ // }
+
+ // return (
+ // <Button
+ // onClick={() => setRowAction({ row, type: "invite" })}
+ // size="sm"
+ // variant="outline"
+ // >
+ // 발행하기
+ // </Button>
+ // )
+ // },
+ // size: 80,
+ // enableSorting: false,
+ // enableHiding: 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/tbe-table/tbe-table-toolbar-actions.tsx b/lib/rfqs/tbe-table/tbe-table-toolbar-actions.tsx
new file mode 100644
index 00000000..6a336135
--- /dev/null
+++ b/lib/rfqs/tbe-table/tbe-table-toolbar-actions.tsx
@@ -0,0 +1,60 @@
+"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()
+ }
+
+ return (
+ <div className="flex items-center gap-2">
+ {table.getFilteredSelectedRowModel().rows.length > 0 ? (
+ <InviteVendorsDialog
+ vendors={table
+ .getFilteredSelectedRowModel()
+ .rows.map((row) => row.original)}
+ rfqId = {rfqId}
+ onSuccess={() => table.toggleAllRowsSelected(false)}
+ />
+ ) : null}
+
+
+ <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/tbe-table/tbe-table.tsx b/lib/rfqs/tbe-table/tbe-table.tsx
new file mode 100644
index 00000000..41eff0dc
--- /dev/null
+++ b/lib/rfqs/tbe-table/tbe-table.tsx
@@ -0,0 +1,190 @@
+"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 { useFeatureFlags } from "./feature-flags-provider"
+import { getColumns } from "./tbe-table-columns"
+import { Vendor, 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"
+
+interface VendorsTableProps {
+ promises: Promise<
+ [
+ Awaited<ReturnType<typeof getTBE>>,
+ ]
+ >
+ rfqId: number
+}
+
+
+export function TbeTable({ promises, rfqId }: VendorsTableProps) {
+ const { featureFlags } = useFeatureFlags()
+
+ // Suspense로 받아온 데이터
+ const [{ data, pageCount }] = React.use(promises)
+
+ console.log(data)
+
+ 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 [selectedRfqIdForComments, setSelectedRfqIdForComments] = React.useState<number | null>(null)
+
+ const [isFileDialogOpen, setIsFileDialogOpen] = React.useState(false)
+ const [selectedVendorId, setSelectedVendorId] = React.useState<number | null>(null)
+ const [selectedTbeId, setSelectedTbeId] = React.useState<number | null>(null)
+
+ console.log(selectedVendorId,"selectedVendorId")
+ console.log(rfqId,"rfqId")
+
+ // Add handleRefresh function
+ const handleRefresh = React.useCallback(() => {
+ router.refresh();
+ }, [router]);
+
+ React.useEffect(() => {
+ if (rowAction?.type === "comments") {
+ // rowAction가 새로 세팅된 뒤 여기서 openCommentSheet 실행
+ openCommentSheet(Number(rowAction.row.original.id))
+ } 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(vendorId: number) {
+ setInitialComments([])
+
+ const comments = rowAction?.row.original.comments
+
+ 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,
+ }
+ })
+ )
+ // 3) state에 저장 -> CommentSheet에서 initialComments로 사용
+ setInitialComments(commentWithAttachments)
+ }
+
+ setSelectedRfqIdForComments(vendorId)
+ setCommentSheetOpen(true)
+ }
+
+ const openFilesDialog = (tbeId: number, vendorId: number) => {
+ setSelectedTbeId(tbeId)
+ setSelectedVendorId(vendorId)
+ setIsFileDialogOpen(true)
+ }
+
+
+ // getColumns() 호출 시, router를 주입
+ const columns = React.useMemo(
+ () => getColumns({ setRowAction, router, openCommentSheet, openFilesDialog }),
+ [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: ["actions"] },
+ },
+ 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={1}
+ open={commentSheetOpen}
+ onOpenChange={setCommentSheetOpen}
+ rfqId={rfqId}
+ vendorId={selectedRfqIdForComments ?? 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}
+ />
+ </div>
+ )
+} \ No newline at end of file
diff --git a/lib/rfqs/validations.ts b/lib/rfqs/validations.ts
new file mode 100644
index 00000000..369e426c
--- /dev/null
+++ b/lib/rfqs/validations.ts
@@ -0,0 +1,274 @@
+import { createSearchParamsCache,
+ parseAsArrayOf,
+ parseAsInteger,
+ parseAsString,
+ parseAsStringEnum,
+} from "nuqs/server"
+import * as z from "zod"
+
+import { getFiltersStateParser, getSortingStateParser } from "@/lib/parsers"
+import { Rfq, rfqs, RfqsView, VendorCbeView, VendorRfqViewBase, VendorTbeView } from "@/db/schema/rfq";
+import { Vendor, vendors } from "@/db/schema/vendors";
+
+export const RfqType = {
+ PURCHASE: "PURCHASE",
+ BUDGETARY: "BUDGETARY"
+} as const;
+
+export type RfqType = typeof RfqType[keyof typeof RfqType];
+
+// =======================
+// 1) SearchParams (목록 필터링/정렬)
+// =======================
+export const searchParamsCache = createSearchParamsCache({
+ flags: parseAsArrayOf(z.enum(["advancedTable", "floatingBar"])).withDefault([]),
+ page: parseAsInteger.withDefault(1),
+ perPage: parseAsInteger.withDefault(10),
+ 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(""),
+ rfqType: parseAsStringEnum(["PURCHASE", "BUDGETARY"]).withDefault("PURCHASE"),
+
+});
+
+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(""),
+ rfqType: parseAsStringEnum(["PURCHASE", "BUDGETARY"]).withDefault("PURCHASE"),
+
+ // 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 (선택적)
+ parentRfqId: z.number().nullable().optional(), // 부모 RFQ ID (선택적)
+ dueDate: z.date(),
+ status: z.enum(["DRAFT", "PUBLISHED", "EVALUATION", "AWARDED"]),
+ rfqType: z.enum([RfqType.PURCHASE, RfqType.BUDGETARY]).default(RfqType.PURCHASE),
+ 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(),
+ rfqType: z.string().default("PURCHASE"), // rfqType 필드 추가
+
+});
+
+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(),
+ parentRfqId: z.number().nullable().optional(), // 부모 RFQ ID (선택적)
+ 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) 정렬 (Rfq 테이블)
+ // getSortingStateParser<Rfq>() → Rfq 테이블의 컬럼명에 맞춘 유효성 검사
+ sort: getSortingStateParser<VendorCbeView>().withDefault([
+ { id: "cbeUpdated", desc: true },
+ ]),
+
+ // 4) 간단 검색 필드
+ vendorName: parseAsString.withDefault(""),
+ vendorCode: parseAsString.withDefault(""),
+ country: parseAsString.withDefault(""),
+ email: parseAsString.withDefault(""),
+ website: parseAsString.withDefault(""),
+
+ cbeResult: parseAsString.withDefault(""),
+ cbeNote: parseAsString.withDefault(""),
+ cbeUpdated: parseAsString.withDefault(""),
+ rfqType: parseAsStringEnum(["PURCHASE", "BUDGETARY"]).withDefault("PURCHASE"),
+
+
+ totalCost: parseAsInteger.withDefault(0),
+ currency: parseAsString.withDefault(""),
+ paymentTerms: parseAsString.withDefault(""),
+ incoterms: parseAsString.withDefault(""),
+ deliverySchedule: 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 GetCBESchema = Awaited<ReturnType<typeof searchParamsCBECache.parse>>;
diff --git a/lib/rfqs/vendor-table/add-vendor-dialog.tsx b/lib/rfqs/vendor-table/add-vendor-dialog.tsx
new file mode 100644
index 00000000..8ec5b9f4
--- /dev/null
+++ b/lib/rfqs/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/vendor-table/comments-sheet.tsx b/lib/rfqs/vendor-table/comments-sheet.tsx
new file mode 100644
index 00000000..3a2a9353
--- /dev/null
+++ b/lib/rfqs/vendor-table/comments-sheet.tsx
@@ -0,0 +1,320 @@
+"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) {
+
+ console.log(initialComments)
+
+ 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/vendor-table/feature-flags-provider.tsx b/lib/rfqs/vendor-table/feature-flags-provider.tsx
new file mode 100644
index 00000000..81131894
--- /dev/null
+++ b/lib/rfqs/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/vendor-table/invite-vendors-dialog.tsx b/lib/rfqs/vendor-table/invite-vendors-dialog.tsx
new file mode 100644
index 00000000..23853e2f
--- /dev/null
+++ b/lib/rfqs/vendor-table/invite-vendors-dialog.tsx
@@ -0,0 +1,177 @@
+"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"
+import { RfqType } from "@/lib/rfqs/validations"
+
+interface DeleteTasksDialogProps
+ extends React.ComponentPropsWithoutRef<typeof Dialog> {
+ vendors: Row<MatchedVendorRow>["original"][]
+ rfqId:number
+ rfqType: RfqType
+ showTrigger?: boolean
+ onSuccess?: () => void
+}
+
+export function InviteVendorsDialog({
+ vendors,
+ rfqId,
+ rfqType,
+ 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)),
+ rfqType
+ })
+
+ 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/vendor-table/vendor-list/vendor-list-table-column.tsx b/lib/rfqs/vendor-table/vendor-list/vendor-list-table-column.tsx
new file mode 100644
index 00000000..bfcbe75b
--- /dev/null
+++ b/lib/rfqs/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/vendor-table/vendor-list/vendor-list-table.tsx b/lib/rfqs/vendor-table/vendor-list/vendor-list-table.tsx
new file mode 100644
index 00000000..c436eebd
--- /dev/null
+++ b/lib/rfqs/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/vendor-table/vendors-table-columns.tsx b/lib/rfqs/vendor-table/vendors-table-columns.tsx
new file mode 100644
index 00000000..f152cec5
--- /dev/null
+++ b/lib/rfqs/vendor-table/vendors-table-columns.tsx
@@ -0,0 +1,276 @@
+"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 { 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,
+ DropdownMenuItem,
+ DropdownMenuRadioGroup,
+ DropdownMenuRadioItem,
+ DropdownMenuSeparator,
+ DropdownMenuShortcut,
+ DropdownMenuSub,
+ DropdownMenuSubContent,
+ DropdownMenuSubTrigger,
+ DropdownMenuTrigger,
+} from "@/components/ui/dropdown-menu"
+import { DataTableColumnHeader } from "@/components/data-table/data-table-column-header"
+import { useRouter } from "next/navigation"
+
+import { vendors } from "@/db/schema/vendors"
+import { DataTableColumnHeaderSimple } from "@/components/data-table/data-table-column-simple-header"
+import { vendorColumnsConfig } from "@/config/vendorColumnsConfig"
+import { Separator } from "@/components/ui/separator"
+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
+ }
+
+ const actionsColumn: ColumnDef<MatchedVendorRow> = {
+ id: "actions",
+ cell: ({ row }) => {
+ const rfq = row.original
+ const status = row.original.rfqVendorStatus
+ const isDisabled = !status || status === 'INVITED' || status === 'ACCEPTED'
+
+ if (isDisabled) {
+ return (
+ <div className="relative group">
+ <Button
+ aria-label="Actions disabled"
+ variant="ghost"
+ className="flex size-8 p-0 opacity-50 cursor-not-allowed"
+ disabled
+ >
+ <Ellipsis className="size-4" aria-hidden="true" />
+ </Button>
+ {/* Tooltip explaining why it's disabled */}
+ <div className="absolute hidden group-hover:block right-0 -bottom-8 bg-popover text-popover-foreground text-xs p-2 rounded shadow-md whitespace-nowrap z-50">
+ 초대 상태에서는 사용할 수 없습니다
+ </div>
+ </div>
+ )
+ }
+
+
+ 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">
+ {/* 기존 기능: status가 INVITED일 때만 표시 */}
+ {(!status || status === 'INVITED') && (
+ <DropdownMenuItem onSelect={() => setRowAction({ row, type: "invite" })}>
+ 발행하기
+ </DropdownMenuItem>
+ )}
+ </DropdownMenuContent>
+ </DropdownMenu>
+ )
+ },
+ size: 40,
+ enableSorting: false,
+ enableHiding: 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/vendor-table/vendors-table-floating-bar.tsx b/lib/rfqs/vendor-table/vendors-table-floating-bar.tsx
new file mode 100644
index 00000000..9b32cf5f
--- /dev/null
+++ b/lib/rfqs/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/vendor-table/vendors-table-toolbar-actions.tsx b/lib/rfqs/vendor-table/vendors-table-toolbar-actions.tsx
new file mode 100644
index 00000000..abb34f85
--- /dev/null
+++ b/lib/rfqs/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/vendor-table/vendors-table.tsx b/lib/rfqs/vendor-table/vendors-table.tsx
new file mode 100644
index 00000000..ae9cba41
--- /dev/null
+++ b/lib/rfqs/vendor-table/vendors-table.tsx
@@ -0,0 +1,210 @@
+"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 { useFeatureFlags } from "./feature-flags-provider"
+import { getColumns } from "./vendors-table-columns"
+import { vendors } from "@/db/schema/vendors"
+import { VendorsTableToolbarActions } from "./vendors-table-toolbar-actions"
+import { VendorsTableFloatingBar } from "./vendors-table-floating-bar"
+import { fetchRfqAttachmentsbyCommentId, getMatchedVendors } from "../service"
+import { InviteVendorsDialog } from "./invite-vendors-dialog"
+import { CommentSheet, MatchedVendorComment } from "./comments-sheet"
+import { MatchedVendorRow } from "@/config/vendorRfbColumnsConfig"
+import { RfqType } from "@/lib/rfqs/validations"
+import { toast } from "sonner"
+
+interface VendorsTableProps {
+ promises: Promise<[Awaited<ReturnType<typeof getMatchedVendors>>]>
+ rfqId: number
+ rfqType: RfqType
+}
+
+export function MatchedVendorsTable({ promises, rfqId, rfqType }: VendorsTableProps) {
+ const { featureFlags } = useFeatureFlags()
+ const { data: session } = useSession() // 세션 정보 가져오기
+
+
+
+ // 1) Suspense로 받아온 데이터
+ const [{ data, pageCount }] = React.use(promises)
+ // data는 MatchedVendorRow[] 형태 (getMatchedVendors에서 반환)
+
+ console.log(data)
+
+ // 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)
+
+ // 4) rowAction이 바뀌면, type이 "comments"인지 확인 후 open
+ React.useEffect(() => {
+ if (rowAction?.type === "comments") {
+ openCommentSheet(rowAction.row.original.id)
+ }
+ }, [rowAction])
+
+ // 5) 댓글 시트 오픈 함수
+ async function openCommentSheet(vendorId: number) {
+ // Clear previous comments
+ setInitialComments([])
+
+ // Start loading
+ setIsLoadingComments(true)
+
+ // Open the sheet immediately with loading state
+ setSelectedVendorIdForComments(vendorId)
+ setCommentSheetOpen(true)
+
+ // (a) 현재 Row의 comments 불러옴
+ const comments = rowAction?.row.original.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)
+ }
+ }
+
+ // 6) 컬럼 정의 (memo)
+ const columns = React.useMemo(
+ () => getColumns({ setRowAction, router, openCommentSheet }),
+ [setRowAction, router]
+ )
+
+ // 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 }],
+ columnPinning: { right: ["actions"] },
+ },
+ // 행의 고유 ID
+ getRowId: (originalRow) => String(originalRow.id),
+ shallow: false,
+ clearOnDefault: true,
+ })
+
+ // 세션에서 userId 추출하고 숫자로 변환
+ const currentUserId = session?.user?.id ? parseInt(session.user.id, 10) : 0
+
+ console.log(currentUserId,"currentUserId")
+
+ 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}
+ rfqType={rfqType}
+ />
+
+ {/* 댓글 시트 */}
+ <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
+ }}
+ />
+ </div>
+ )
+} \ No newline at end of file