summaryrefslogtreecommitdiff
path: root/lib/vendor-rfq-response
diff options
context:
space:
mode:
Diffstat (limited to 'lib/vendor-rfq-response')
-rw-r--r--lib/vendor-rfq-response/service.ts181
-rw-r--r--lib/vendor-rfq-response/types.ts2
-rw-r--r--lib/vendor-rfq-response/vendor-cbe-table/cbe-table-columns.tsx365
-rw-r--r--lib/vendor-rfq-response/vendor-cbe-table/cbe-table.tsx272
-rw-r--r--lib/vendor-rfq-response/vendor-cbe-table/comments-sheet.tsx323
-rw-r--r--lib/vendor-rfq-response/vendor-cbe-table/respond-cbe-sheet.tsx427
-rw-r--r--lib/vendor-rfq-response/vendor-cbe-table/rfq-detail-dialog.tsx89
-rw-r--r--lib/vendor-rfq-response/vendor-cbe-table/rfq-items-table/rfq-items-table-column.tsx62
-rw-r--r--lib/vendor-rfq-response/vendor-cbe-table/rfq-items-table/rfq-items-table.tsx86
-rw-r--r--lib/vendor-rfq-response/vendor-tbe-table/comments-sheet.tsx14
-rw-r--r--lib/vendor-rfq-response/vendor-tbe-table/feature-flags-provider.tsx108
-rw-r--r--lib/vendor-rfq-response/vendor-tbe-table/rfq-detail-dialog.tsx86
-rw-r--r--lib/vendor-rfq-response/vendor-tbe-table/tbe-table-columns.tsx65
-rw-r--r--lib/vendor-rfq-response/vendor-tbe-table/tbe-table.tsx72
-rw-r--r--lib/vendor-rfq-response/vendor-tbe-table/tbeFileHandler.tsx4
15 files changed, 1996 insertions, 160 deletions
diff --git a/lib/vendor-rfq-response/service.ts b/lib/vendor-rfq-response/service.ts
index cba6c414..8f2954d7 100644
--- a/lib/vendor-rfq-response/service.ts
+++ b/lib/vendor-rfq-response/service.ts
@@ -1,10 +1,14 @@
-import { unstable_cache } from "next/cache";
+'use server'
+
+import { revalidateTag, unstable_cache } from "next/cache";
import db from "@/db/db";
import { and, desc, eq, inArray, isNull, or, sql } from "drizzle-orm";
-import { rfqAttachments, rfqComments, rfqItems } from "@/db/schema/rfq";
+import { rfqAttachments, rfqComments, rfqItems, vendorResponses } from "@/db/schema/rfq";
import { vendorResponsesView, vendorTechnicalResponses, vendorCommercialResponses, vendorResponseAttachments } from "@/db/schema/rfq";
import { items } from "@/db/schema/items";
import { GetRfqsForVendorsSchema } from "../rfqs/validations";
+import { ItemData } from "./vendor-cbe-table/rfq-items-table/rfq-items-table";
+import * as z from "zod"
@@ -27,7 +31,7 @@ export async function getRfqResponsesForVendor(input: GetRfqsForVendorsSchema, v
);
}
- // 벤더 ID 필터링
+ // 협력업체 ID 필터링
const mainWhere = and(eq(vendorResponsesView.vendorId, vendorId), globalWhere);
// 정렬: 응답 시간순
@@ -75,7 +79,7 @@ export async function getRfqResponsesForVendor(input: GetRfqsForVendorsSchema, v
.leftJoin(items, eq(rfqItems.itemCode, items.itemCode))
.where(inArray(rfqItems.rfqId, distinctRfqs));
- // 3-B) RFQ 첨부 파일 (벤더용)
+ // 3-B) RFQ 첨부 파일 (협력업체용)
const attachAll = await db
.select()
.from(rfqAttachments)
@@ -101,7 +105,7 @@ export async function getRfqResponsesForVendor(input: GetRfqsForVendorsSchema, v
);
- // 3-E) 벤더 응답 상세 - 기술
+ // 3-E) 협력업체 응답 상세 - 기술
const technicalResponsesAll = await db
.select()
.from(vendorTechnicalResponses)
@@ -112,7 +116,7 @@ export async function getRfqResponsesForVendor(input: GetRfqsForVendorsSchema, v
)
);
- // 3-F) 벤더 응답 상세 - 상업
+ // 3-F) 협력업체 응답 상세 - 상업
const commercialResponsesAll = await db
.select()
.from(vendorCommercialResponses)
@@ -123,7 +127,7 @@ export async function getRfqResponsesForVendor(input: GetRfqsForVendorsSchema, v
)
);
- // 3-G) 벤더 응답 첨부 파일
+ // 3-G) 협력업체 응답 첨부 파일
const responseAttachmentsAll = await db
.select()
.from(vendorResponseAttachments)
@@ -257,7 +261,7 @@ export async function getRfqResponsesForVendor(input: GetRfqsForVendorsSchema, v
projectCode: row.projectCode,
projectName: row.projectName,
- // 벤더 정보
+ // 협력업체 정보
vendorId: row.vendorId,
vendorName: row.vendorName,
vendorCode: row.vendorCode,
@@ -277,7 +281,7 @@ export async function getRfqResponsesForVendor(input: GetRfqsForVendorsSchema, v
result: row.cbeResult,
} : null,
- // 벤더 응답 상세
+ // 협력업체 응답 상세
technicalResponse: techResponseByResponseId.get(row.responseId) || null,
commercialResponse: commResponseByResponseId.get(row.responseId) || null,
responseAttachments: respAttachByResponseId.get(row.responseId) || [],
@@ -298,4 +302,163 @@ export async function getRfqResponsesForVendor(input: GetRfqsForVendorsSchema, v
tags: ["rfqs-vendor", `vendor-${vendorId}`],
}
)();
+}
+
+
+export async function getItemsByRfqId(rfqId: number): Promise<ResponseType> {
+ try {
+ if (!rfqId || isNaN(Number(rfqId))) {
+ return {
+ success: false,
+ error: "Invalid RFQ ID provided",
+ }
+ }
+
+ // Query the database to get all items for the given RFQ ID
+ const items = await db
+ .select()
+ .from(rfqItems)
+ .where(eq(rfqItems.rfqId, rfqId))
+ .orderBy(rfqItems.itemCode)
+
+
+ return {
+ success: true,
+ data: items as ItemData[],
+ }
+ } catch (error) {
+ console.error("Error fetching RFQ items:", error)
+
+ return {
+ success: false,
+ error: error instanceof Error ? error.message : "Unknown error occurred when fetching RFQ items",
+ }
+ }
+}
+
+
+// Define the schema for validation
+const commercialResponseSchema = z.object({
+ responseId: z.number(),
+ vendorId: z.number(), // Added vendorId field
+ responseStatus: z.enum(["PENDING", "IN_PROGRESS", "SUBMITTED", "REJECTED", "ACCEPTED"]),
+ totalPrice: z.number().optional(),
+ currency: z.string().default("USD"),
+ paymentTerms: z.string().optional(),
+ incoterms: z.string().optional(),
+ deliveryPeriod: z.string().optional(),
+ warrantyPeriod: z.string().optional(),
+ validityPeriod: z.string().optional(),
+ priceBreakdown: z.string().optional(),
+ commercialNotes: z.string().optional(),
+})
+
+type CommercialResponseInput = z.infer<typeof commercialResponseSchema>
+
+interface ResponseType {
+ success: boolean
+ error?: string
+ data?: any
+}
+
+export async function updateCommercialResponse(input: CommercialResponseInput): Promise<ResponseType> {
+ try {
+ // Validate input data
+ const validated = commercialResponseSchema.parse(input)
+
+ // Check if a commercial response already exists for this responseId
+ const existingResponse = await db
+ .select()
+ .from(vendorCommercialResponses)
+ .where(eq(vendorCommercialResponses.responseId, validated.responseId))
+ .limit(1)
+
+ const now = new Date()
+
+ if (existingResponse.length > 0) {
+ // Update existing record
+ await db
+ .update(vendorCommercialResponses)
+ .set({
+ responseStatus: validated.responseStatus,
+ totalPrice: validated.totalPrice,
+ currency: validated.currency,
+ paymentTerms: validated.paymentTerms,
+ incoterms: validated.incoterms,
+ deliveryPeriod: validated.deliveryPeriod,
+ warrantyPeriod: validated.warrantyPeriod,
+ validityPeriod: validated.validityPeriod,
+ priceBreakdown: validated.priceBreakdown,
+ commercialNotes: validated.commercialNotes,
+ updatedAt: now,
+ })
+ .where(eq(vendorCommercialResponses.responseId, validated.responseId))
+
+ } else {
+ // Return error instead of creating a new record
+ return {
+ success: false,
+ error: "해당 응답 ID에 대한 상업 응답 정보를 찾을 수 없습니다."
+ }
+ }
+
+ // Also update the main vendor response status if submitted
+ if (validated.responseStatus === "SUBMITTED") {
+ // Get the vendor response
+ const vendorResponseResult = await db
+ .select()
+ .from(vendorResponses)
+ .where(eq(vendorResponses.id, validated.responseId))
+ .limit(1)
+
+ if (vendorResponseResult.length > 0) {
+ // Update the main response status to RESPONDED
+ await db
+ .update(vendorResponses)
+ .set({
+ responseStatus: "RESPONDED",
+ updatedAt: now,
+ })
+ .where(eq(vendorResponses.id, validated.responseId))
+ }
+ }
+
+ // Use vendorId for revalidateTag
+ revalidateTag(`cbe-vendor-${validated.vendorId}`)
+
+ return {
+ success: true,
+ data: { responseId: validated.responseId }
+ }
+
+ } catch (error) {
+ console.error("Error updating commercial response:", error)
+
+ if (error instanceof z.ZodError) {
+ return {
+ success: false,
+ error: "유효하지 않은 데이터가 제공되었습니다."
+ }
+ }
+
+ return {
+ success: false,
+ error: error instanceof Error ? error.message : "Unknown error occurred"
+ }
+ }
+}
+// Helper function to get responseId from rfqId and vendorId
+export async function getCommercialResponseByResponseId(responseId: number): Promise<any | null> {
+ try {
+ const response = await db
+ .select()
+ .from(vendorCommercialResponses)
+ .where(eq(vendorCommercialResponses.responseId, responseId))
+ .limit(1)
+
+ return response.length > 0 ? response[0] : null
+ } catch (error) {
+ console.error("Error getting commercial response:", error)
+ return null
+ }
} \ No newline at end of file
diff --git a/lib/vendor-rfq-response/types.ts b/lib/vendor-rfq-response/types.ts
index 5dadc89b..3f595ebb 100644
--- a/lib/vendor-rfq-response/types.ts
+++ b/lib/vendor-rfq-response/types.ts
@@ -50,7 +50,7 @@ export interface RfqResponse {
projectCode?: string | null;
projectName?: string | null;
- // 벤더 정보
+ // 협력업체 정보
vendorId: number;
vendorName: string;
vendorCode?: string | null;
diff --git a/lib/vendor-rfq-response/vendor-cbe-table/cbe-table-columns.tsx b/lib/vendor-rfq-response/vendor-cbe-table/cbe-table-columns.tsx
new file mode 100644
index 00000000..c7be0bf4
--- /dev/null
+++ b/lib/vendor-rfq-response/vendor-cbe-table/cbe-table-columns.tsx
@@ -0,0 +1,365 @@
+"use client"
+
+import * as React from "react"
+import { type DataTableRowAction } from "@/types/table"
+import { type ColumnDef } from "@tanstack/react-table"
+import { Download, Loader2, MessageSquare, FileEdit } from "lucide-react"
+import { formatDate } from "@/lib/utils"
+import { Badge } from "@/components/ui/badge"
+import { Button } from "@/components/ui/button"
+import { Checkbox } from "@/components/ui/checkbox"
+import { DataTableColumnHeaderSimple } from "@/components/data-table/data-table-column-simple-header"
+import { useRouter } from "next/navigation"
+import { VendorWithCbeFields, vendorResponseCbeColumnsConfig } from "@/config/vendorCbeColumnsConfig"
+import { toast } from "sonner"
+
+
+type NextRouter = ReturnType<typeof useRouter>
+
+interface GetColumnsProps {
+ setRowAction: React.Dispatch<
+ React.SetStateAction<DataTableRowAction<VendorWithCbeFields> | null>
+ >
+ router: NextRouter
+ openCommentSheet: (vendorId: number) => void
+ handleDownloadCbeFiles: (vendorId: number, rfqId: number) => void
+ loadingVendors: Record<string, boolean>
+ openVendorContactsDialog: (rfqId: number, rfq: VendorWithCbeFields) => void
+ // New prop for handling commercial response
+ openCommercialResponseSheet: (responseId: number, rfq: VendorWithCbeFields) => void
+}
+
+/**
+ * tanstack table 컬럼 정의 (중첩 헤더 버전)
+ */
+export function getColumns({
+ setRowAction,
+ router,
+ openCommentSheet,
+ handleDownloadCbeFiles,
+ loadingVendors,
+ openVendorContactsDialog,
+ openCommercialResponseSheet
+}: 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>[]> = {}
+
+ vendorResponseCbeColumnsConfig.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,
+ },
+ maxSize: 120,
+ // 셀 렌더링
+ cell: ({ row, getValue }) => {
+ // 1) 필드값 가져오기
+ const val = getValue()
+
+
+ if (cfg.id === "rfqCode") {
+ const rfq = row.original;
+ const rfqId = rfq.rfqId;
+
+ // 협력업체 이름을 클릭할 수 있는 버튼으로 렌더링
+ const handleVendorNameClick = () => {
+ if (rfqId) {
+ openVendorContactsDialog(rfqId, rfq); // vendor 전체 객체 전달
+ } else {
+ toast.error("협력업체 ID를 찾을 수 없습니다.");
+ }
+ };
+
+ return (
+ <Button
+ variant="link"
+ className="p-0 h-auto text-left font-normal justify-start hover:underline"
+ onClick={handleVendorNameClick}
+ >
+ {val as string}
+ </Button>
+ );
+ }
+
+ // Commercial Response Status에 배지 적용
+ if (cfg.id === "commercialResponseStatus") {
+ const status = val as string;
+
+ if (!status) return <span className="text-muted-foreground">-</span>;
+
+ let variant: "default" | "outline" | "secondary" | "destructive" = "outline";
+
+ switch (status) {
+ case "SUBMITTED":
+ variant = "default"; // Green
+ break;
+ case "IN_PROGRESS":
+ variant = "secondary"; // Orange/Yellow
+ break;
+ case "PENDING":
+ variant = "outline"; // Gray
+ break;
+ default:
+ variant = "outline";
+ }
+
+ return (
+ <Badge variant={variant} className="capitalize">
+ {status.toLowerCase().replace("_", " ")}
+ </Badge>
+ );
+ }
+
+ // 예) TBE Updated (날짜)
+ if (cfg.id === "respondedAt" || cfg.id === "rfqDueDate" ) {
+ 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) Respond 컬럼 (새로 추가)
+ // ----------------------------------------------------------------
+ const respondColumn: ColumnDef<VendorWithCbeFields> = {
+ id: "respond",
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="Response" />
+ ),
+ cell: ({ row }) => {
+ const vendor = row.original
+ const responseId = vendor.responseId
+
+ if (!responseId) {
+ return <div className="text-center text-muted-foreground">-</div>
+ }
+
+ const handleClick = () => {
+ openCommercialResponseSheet(responseId, vendor)
+ }
+
+ // Status에 따라 버튼 variant 변경
+ let variant: "default" | "outline" | "ghost" | "secondary" = "default"
+ let buttonText = "Respond"
+
+ if (vendor.commercialResponseStatus === "SUBMITTED") {
+ variant = "outline"
+ buttonText = "Update"
+ } else if (vendor.commercialResponseStatus === "IN_PROGRESS") {
+ variant = "secondary"
+ buttonText = "Continue"
+ }
+
+ return (
+ <Button
+ variant={variant}
+ size="sm"
+ // className="w-20"
+ onClick={handleClick}
+ >
+ <FileEdit className="h-3.5 w-3.5 mr-1" />
+ {buttonText}
+ </Button>
+ )
+ },
+ enableSorting: false,
+ maxSize: 200,
+ minSize: 115,
+ }
+
+ // ----------------------------------------------------------------
+ // 4) Comments 컬럼
+ // ----------------------------------------------------------------
+ const commentsColumn: ColumnDef<VendorWithCbeFields> = {
+ id: "comments",
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="Comments" />
+ ),
+ cell: ({ row }) => {
+ const vendor = row.original
+ const commCount = vendor.comments?.length ?? 0
+
+ function handleClick() {
+ // rowAction + openCommentSheet
+ setRowAction({ row, type: "comments" })
+ openCommentSheet(vendor.responseId ?? 0)
+ }
+
+ return (
+ <Button
+ variant="ghost"
+ size="sm"
+ className="relative h-8 w-8 p-0 group"
+ onClick={handleClick}
+ aria-label={
+ commCount > 0 ? `View ${commCount} comments` : "No comments"
+ }
+ >
+ <MessageSquare className="h-4 w-4 text-muted-foreground group-hover:text-primary transition-colors" />
+ {commCount > 0 && (
+ <Badge
+ variant="secondary"
+ className="pointer-events-none absolute -top-1 -right-1 h-4 min-w-[1rem] p-0 text-[0.625rem] leading-none flex items-center justify-center"
+ >
+ {commCount}
+ </Badge>
+ )}
+ <span className="sr-only">
+ {commCount > 0 ? `${commCount} Comments` : "No Comments"}
+ </span>
+ </Button>
+ )
+ },
+ enableSorting: false,
+ maxSize: 80
+ }
+
+ // ----------------------------------------------------------------
+ // 5) 파일 다운로드 컬럼 (개별 로딩 상태 적용)
+ // ----------------------------------------------------------------
+ const downloadColumn: ColumnDef<VendorWithCbeFields> = {
+ id: "attachDownload",
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="Attach Download" />
+ ),
+ cell: ({ row }) => {
+ const vendor = row.original
+ const vendorId = vendor.vendorId
+ const rfqId = vendor.rfqId
+ const files = vendor.files?.length || 0
+
+ if (!vendorId || !rfqId) {
+ return <div className="text-center text-muted-foreground">-</div>
+ }
+
+ // 각 행별로 로딩 상태 확인 (vendorId_rfqId 형식의 키 사용)
+ const rowKey = `${vendorId}_${rfqId}`
+ const isRowLoading = loadingVendors[rowKey] === true
+
+ // 템플릿 파일이 없으면 다운로드 버튼 비활성화
+ const isDisabled = files <= 0 || isRowLoading
+
+ return (
+ <Button
+ variant="ghost"
+ size="sm"
+ className="relative h-8 w-8 p-0 group"
+ onClick={
+ isDisabled
+ ? undefined
+ : () => handleDownloadCbeFiles(vendorId, rfqId)
+ }
+ aria-label={
+ isRowLoading
+ ? "다운로드 중..."
+ : files > 0
+ ? `CBE 첨부 다운로드 (${files}개)`
+ : "다운로드할 파일 없음"
+ }
+ disabled={isDisabled}
+ >
+ {isRowLoading ? (
+ <Loader2 className="h-4 w-4 animate-spin" />
+ ) : (
+ <Download className="h-4 w-4 text-muted-foreground group-hover:text-primary transition-colors" />
+ )}
+
+ {/* 파일이 1개 이상인 경우 뱃지로 개수 표시 (로딩 중이 아닐 때만) */}
+ {!isRowLoading && files > 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"
+ >
+ {files}
+ </Badge>
+ )}
+
+ <span className="sr-only">
+ {isRowLoading
+ ? "다운로드 중..."
+ : files > 0
+ ? `CBE 첨부 다운로드 (${files}개)`
+ : "다운로드할 파일 없음"}
+ </span>
+ </Button>
+ )
+ },
+ enableSorting: false,
+ maxSize: 80,
+ }
+
+ // ----------------------------------------------------------------
+ // 6) 최종 컬럼 배열 (respondColumn 추가)
+ // ----------------------------------------------------------------
+ return [
+ selectColumn,
+ ...nestedColumns,
+ respondColumn, // 응답 컬럼 추가
+ downloadColumn,
+ commentsColumn,
+ ]
+} \ No newline at end of file
diff --git a/lib/vendor-rfq-response/vendor-cbe-table/cbe-table.tsx b/lib/vendor-rfq-response/vendor-cbe-table/cbe-table.tsx
new file mode 100644
index 00000000..8477f550
--- /dev/null
+++ b/lib/vendor-rfq-response/vendor-cbe-table/cbe-table.tsx
@@ -0,0 +1,272 @@
+"use client"
+
+import * as React from "react"
+import { useRouter } from "next/navigation"
+import type {
+ DataTableAdvancedFilterField,
+ DataTableFilterField,
+ DataTableRowAction,
+} from "@/types/table"
+
+import { useDataTable } from "@/hooks/use-data-table"
+import { DataTable } from "@/components/data-table/data-table"
+import { DataTableAdvancedToolbar } from "@/components/data-table/data-table-advanced-toolbar"
+import { getColumns } from "./cbe-table-columns"
+import {
+ fetchRfqAttachmentsbyCommentId,
+ getCBEbyVendorId,
+ getFileFromRfqAttachmentsbyid,
+ fetchCbeFiles
+} from "../../rfqs/service"
+import { useSession } from "next-auth/react"
+import { CbeComment, CommentSheet } from "./comments-sheet"
+import { VendorWithCbeFields } from "@/config/vendorCbeColumnsConfig"
+import { toast } from "sonner"
+import { RfqDeailDialog } from "./rfq-detail-dialog"
+import { CommercialResponseSheet } from "./respond-cbe-sheet"
+
+interface VendorsTableProps {
+ promises: Promise<
+ [
+ Awaited<ReturnType<typeof getCBEbyVendorId>>,
+ ]
+ >
+}
+
+export function CbeVendorTable({ promises }: VendorsTableProps) {
+ const { data: session } = useSession()
+ const userVendorId = session?.user?.companyId
+ const userId = Number(session?.user?.id)
+ // Suspense로 받아온 데이터
+ const [{ data, pageCount }] = React.use(promises)
+ const [rowAction, setRowAction] = React.useState<DataTableRowAction<VendorWithCbeFields> | null>(null)
+ const [selectedCbeId, setSelectedCbeId] = React.useState<number | null>(null)
+
+ // 개별 협력업체별 로딩 상태를 관리하는 맵
+ const [loadingVendors, setLoadingVendors] = React.useState<Record<string, boolean>>({})
+
+ const router = useRouter()
+
+ // 코멘트 관련 상태
+ const [initialComments, setInitialComments] = React.useState<CbeComment[]>([])
+ const [commentSheetOpen, setCommentSheetOpen] = React.useState(false)
+ const [selectedRfqIdForComments, setSelectedRfqIdForComments] = React.useState<number | null>(null)
+
+ // 상업 응답 관련 상태
+ const [commercialResponseSheetOpen, setCommercialResponseSheetOpen] = React.useState(false)
+ const [selectedResponseId, setSelectedResponseId] = React.useState<number | null>(null)
+ const [selectedRfq, setSelectedRfq] = React.useState<VendorWithCbeFields | null>(null)
+
+ // RFQ 상세 관련 상태
+ const [rfqDetailDialogOpen, setRfqDetailDialogOpen] = React.useState(false)
+ const [selectedRfqId, setSelectedRfqId] = React.useState<number | null>(null)
+ const [selectedRfqDetail, setSelectedRfqDetail] = React.useState<VendorWithCbeFields | null>(null)
+
+ React.useEffect(() => {
+ if (rowAction?.type === "comments") {
+ // rowAction가 새로 세팅된 뒤 여기서 openCommentSheet 실행
+ openCommentSheet(Number(rowAction.row.original.responseId))
+ }
+ }, [rowAction])
+
+ async function openCommentSheet(responseId: number) {
+ setInitialComments([])
+
+ const comments = rowAction?.row.original.comments
+ const rfqId = rowAction?.row.original.rfqId
+
+ if (comments && comments.length > 0) {
+ const commentWithAttachments: CbeComment[] = await Promise.all(
+ comments.map(async (c) => {
+ // 서버 액션을 사용하여 코멘트 첨부 파일 가져오기
+ const attachments = await fetchRfqAttachmentsbyCommentId(c.id)
+
+ return {
+ ...c,
+ commentedBy: userId, // DB나 API 응답에 있다고 가정
+ attachments,
+ }
+ })
+ )
+
+ setInitialComments(commentWithAttachments)
+ }
+
+ if(rfqId) {
+ setSelectedRfqIdForComments(rfqId)
+ }
+ setSelectedCbeId(responseId)
+ setCommentSheetOpen(true)
+ }
+
+ // 상업 응답 시트 열기
+ function openCommercialResponseSheet(responseId: number, rfq: VendorWithCbeFields) {
+ setSelectedResponseId(responseId)
+ setSelectedRfq(rfq)
+ setCommercialResponseSheetOpen(true)
+ }
+
+ // RFQ 상세 대화상자 열기
+ function openRfqDetailDialog(rfqId: number, rfq: VendorWithCbeFields) {
+ setSelectedRfqId(rfqId)
+ setSelectedRfqDetail(rfq)
+ setRfqDetailDialogOpen(true)
+ }
+
+ const handleDownloadCbeFiles = React.useCallback(
+ async (vendorId: number, rfqId: number) => {
+ // 고유 키 생성: vendorId_rfqId
+ const rowKey = `${vendorId}_${rfqId}`
+
+ // 해당 협력업체의 로딩 상태만 true로 설정
+ setLoadingVendors(prev => ({
+ ...prev,
+ [rowKey]: true
+ }))
+
+ try {
+ const { files, error } = await fetchCbeFiles(vendorId, rfqId);
+ if (error) {
+ toast.error(error);
+ return;
+ }
+ if (files.length === 0) {
+ toast.warning("다운로드할 CBE 파일이 없습니다");
+ return;
+ }
+ // 순차적으로 파일 다운로드
+ for (const file of files) {
+ await downloadFile(file.id);
+ }
+ toast.success(`${files.length}개의 CBE 파일이 다운로드되었습니다`);
+ } catch (error) {
+ toast.error("CBE 파일을 다운로드하는 데 실패했습니다");
+ console.error(error);
+ } finally {
+ // 해당 협력업체의 로딩 상태만 false로 되돌림
+ setLoadingVendors(prev => ({
+ ...prev,
+ [rowKey]: false
+ }))
+ }
+ },
+ []
+ );
+
+ const downloadFile = React.useCallback(async (fileId: number) => {
+ try {
+ const { file, error } = await getFileFromRfqAttachmentsbyid(fileId);
+ if (error || !file) {
+ throw new Error(error || "파일 정보를 가져오는 데 실패했습니다");
+ }
+
+ const link = document.createElement("a");
+ link.href = `/api/rfq-download?path=${encodeURIComponent(file.filePath)}`;
+ link.download = file.fileName;
+ document.body.appendChild(link);
+ link.click();
+ document.body.removeChild(link);
+
+ return true;
+ } catch (error) {
+ console.error(error);
+ return false;
+ }
+ }, []);
+
+ // 응답 성공 후 데이터 갱신
+ const handleResponseSuccess = React.useCallback(() => {
+ // 필요한 경우 데이터 다시 가져오기
+ router.refresh()
+ }, [router]);
+
+ // getColumns() 호출 시 필요한 핸들러들 주입
+ const columns = React.useMemo(
+ () => getColumns({
+ setRowAction,
+ router,
+ openCommentSheet,
+ handleDownloadCbeFiles,
+ loadingVendors,
+ openVendorContactsDialog: openRfqDetailDialog,
+ openCommercialResponseSheet,
+ }),
+ [
+ setRowAction,
+ router,
+ openCommentSheet,
+ handleDownloadCbeFiles,
+ loadingVendors,
+ openRfqDetailDialog,
+ openCommercialResponseSheet
+ ]
+ );
+
+ // 필터 필드 정의
+ const filterFields: DataTableFilterField<VendorWithCbeFields>[] = []
+ const advancedFilterFields: DataTableAdvancedFilterField<VendorWithCbeFields>[] = [
+
+ ]
+
+ const { table } = useDataTable({
+ data,
+ columns,
+ pageCount,
+ filterFields,
+ enablePinning: true,
+ enableAdvancedFilter: true,
+ initialState: {
+ sorting: [{ id: "respondedAt", desc: true }],
+ columnPinning: { right: ["respond", "comments"] }, // respond 컬럼을 오른쪽에 고정
+ },
+ getRowId: (originalRow) => String(originalRow.responseId),
+ shallow: false,
+ clearOnDefault: true,
+ })
+
+ return (
+ <>
+ <DataTable table={table}>
+ <DataTableAdvancedToolbar
+ table={table}
+ filterFields={advancedFilterFields}
+ shallow={false}
+ />
+ </DataTable>
+
+ {/* 코멘트 시트 */}
+ {commentSheetOpen && selectedRfqIdForComments && selectedCbeId !== null && (
+ <CommentSheet
+ open={commentSheetOpen}
+ onOpenChange={setCommentSheetOpen}
+ rfqId={selectedRfqIdForComments}
+ initialComments={initialComments}
+ vendorId={userVendorId || 0}
+ currentUserId={userId || 0}
+ cbeId={selectedCbeId}
+ />
+ )}
+
+ {/* 상업 응답 시트 */}
+ {commercialResponseSheetOpen && selectedResponseId !== null && selectedRfq && (
+ <CommercialResponseSheet
+ open={commercialResponseSheetOpen}
+ onOpenChange={setCommercialResponseSheetOpen}
+ responseId={selectedResponseId}
+ rfq={selectedRfq}
+ onSuccess={handleResponseSuccess}
+ />
+ )}
+
+ {/* RFQ 상세 대화상자 */}
+ {rfqDetailDialogOpen && selectedRfqId !== null && (
+ <RfqDeailDialog
+ isOpen={rfqDetailDialogOpen}
+ onOpenChange={setRfqDetailDialogOpen}
+ rfqId={selectedRfqId}
+ rfq={selectedRfqDetail}
+ />
+ )}
+ </>
+ )
+} \ No newline at end of file
diff --git a/lib/vendor-rfq-response/vendor-cbe-table/comments-sheet.tsx b/lib/vendor-rfq-response/vendor-cbe-table/comments-sheet.tsx
new file mode 100644
index 00000000..40d38145
--- /dev/null
+++ b/lib/vendor-rfq-response/vendor-cbe-table/comments-sheet.tsx
@@ -0,0 +1,323 @@
+"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 { formatDate } from "@/lib/utils"
+import { createRfqCommentWithAttachments } from "@/lib/rfqs/service"
+
+
+export interface CbeComment {
+ id: number
+ commentText: string
+ commentedBy?: number
+ commentedByEmail?: string
+ createdAt?: Date
+ attachments?: {
+ id: number
+ fileName: string
+ filePath: string
+ }[]
+}
+
+// 1) props 정의
+interface CommentSheetProps extends React.ComponentPropsWithRef<typeof Sheet> {
+ initialComments?: CbeComment[]
+ currentUserId: number
+ rfqId: number
+ tbeId?: number
+ cbeId?: number
+ vendorId: number
+ onCommentsUpdated?: (comments: CbeComment[]) => void
+ isLoading?: boolean // New prop
+}
+
+// 2) 폼 스키마
+const commentFormSchema = z.object({
+ commentText: z.string().min(1, "댓글을 입력하세요."),
+ newFiles: z.array(z.any()).optional(), // File[]
+})
+type CommentFormValues = z.infer<typeof commentFormSchema>
+
+const MAX_FILE_SIZE = 30e6 // 30MB
+
+export function CommentSheet({
+ rfqId,
+ vendorId,
+ initialComments = [],
+ currentUserId,
+ tbeId,
+ cbeId,
+ onCommentsUpdated,
+ isLoading = false, // Default to false
+ ...props
+}: CommentSheetProps) {
+
+
+ const [comments, setComments] = React.useState<CbeComment[]>(initialComments)
+ const [isPending, startTransition] = React.useTransition()
+
+ React.useEffect(() => {
+ setComments(initialComments)
+ }, [initialComments])
+
+ const form = useForm<CommentFormValues>({
+ resolver: zodResolver(commentFormSchema),
+ defaultValues: {
+ commentText: "",
+ newFiles: [],
+ },
+ })
+
+ const { fields: newFileFields, append, remove } = useFieldArray({
+ control: form.control,
+ name: "newFiles",
+ })
+
+ // (A) 기존 코멘트 렌더링
+ function renderExistingComments() {
+
+ if (isLoading) {
+ return (
+ <div className="flex justify-center items-center h-32">
+ <Loader2 className="h-4 w-4 animate-spin text-muted-foreground" />
+ <span className="ml-2 text-sm text-muted-foreground">Loading comments...</span>
+ </div>
+ )
+ }
+
+ if (comments.length === 0) {
+ return <p className="text-sm text-muted-foreground">No comments yet</p>
+ }
+ return (
+ <Table>
+ <TableHeader>
+ <TableRow>
+ <TableHead className="w-1/2">Comment</TableHead>
+ <TableHead>Attachments</TableHead>
+ <TableHead>Created At</TableHead>
+ <TableHead>Created By</TableHead>
+ </TableRow>
+ </TableHeader>
+ <TableBody>
+ {comments.map((c) => (
+ <TableRow key={c.id}>
+ <TableCell>{c.commentText}</TableCell>
+ <TableCell>
+ {!c.attachments?.length && (
+ <span className="text-sm text-muted-foreground">No files</span>
+ )}
+ {c.attachments?.length && (
+ <div className="flex flex-col gap-1">
+ {c.attachments.map((att) => (
+ <div key={att.id} className="flex items-center gap-2">
+ <a
+ href={`/api/rfq-download?path=${encodeURIComponent(att.filePath)}`}
+ download
+ target="_blank"
+ rel="noreferrer"
+ className="inline-flex items-center gap-1 text-blue-600 underline"
+ >
+ <Download className="h-4 w-4" />
+ {att.fileName}
+ </a>
+ </div>
+ ))}
+ </div>
+ )}
+ </TableCell>
+ <TableCell> {c.createdAt ? formatDate(c.createdAt) : "-"}</TableCell>
+ <TableCell>{c.commentedByEmail ?? "-"}</TableCell>
+ </TableRow>
+ ))}
+ </TableBody>
+ </Table>
+ )
+ }
+
+ // (B) 파일 드롭
+ function handleDropAccepted(files: File[]) {
+ append(files)
+ }
+
+ // (C) Submit
+ async function onSubmit(data: CommentFormValues) {
+ if (!rfqId) return
+ startTransition(async () => {
+ try {
+ const res = await createRfqCommentWithAttachments({
+ rfqId,
+ vendorId,
+ commentText: data.commentText,
+ commentedBy: currentUserId,
+ evaluationId: null,
+ cbeId: cbeId,
+ files: data.newFiles,
+ })
+
+ if (!res.ok) {
+ throw new Error("Failed to create comment")
+ }
+
+ toast.success("Comment created")
+
+ // 임시로 새 코멘트 추가
+ const newComment: CbeComment = {
+ id: res.commentId, // 서버 응답
+ commentText: data.commentText,
+ commentedBy: currentUserId,
+ createdAt: res.createdAt,
+ attachments:
+ data.newFiles?.map((f) => ({
+ id: Math.floor(Math.random() * 1e6),
+ fileName: f.name,
+ filePath: "/uploads/" + f.name,
+ })) || [],
+ }
+ setComments((prev) => [...prev, newComment])
+ onCommentsUpdated?.([...comments, newComment])
+
+ form.reset()
+ } catch (err: any) {
+ console.error(err)
+ toast.error("Error: " + err.message)
+ }
+ })
+ }
+
+ return (
+ <Sheet {...props}>
+ <SheetContent className="flex flex-col gap-6 sm:max-w-lg">
+ <SheetHeader className="text-left">
+ <SheetTitle>Comments</SheetTitle>
+ <SheetDescription>
+ 필요시 첨부파일과 함께 문의/코멘트를 남길 수 있습니다.
+ </SheetDescription>
+ </SheetHeader>
+
+ <div className="max-h-[300px] overflow-y-auto">{renderExistingComments()}</div>
+
+ <Form {...form}>
+ <form onSubmit={form.handleSubmit(onSubmit)} className="flex flex-col gap-4">
+ <FormField
+ control={form.control}
+ name="commentText"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>New Comment</FormLabel>
+ <FormControl>
+ <Textarea placeholder="Enter your comment..." {...field} />
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+
+ <Dropzone
+ maxSize={MAX_FILE_SIZE}
+ onDropAccepted={handleDropAccepted}
+ onDropRejected={(rej) => {
+ toast.error("File rejected: " + (rej[0]?.file?.name || ""))
+ }}
+ >
+ {({ maxSize }) => (
+ <DropzoneZone className="flex justify-center">
+ <DropzoneInput />
+ <div className="flex items-center gap-6">
+ <DropzoneUploadIcon />
+ <div className="grid gap-0.5">
+ <DropzoneTitle>Drop to attach files</DropzoneTitle>
+ <DropzoneDescription>
+ Max size: {prettyBytes(maxSize || 0)}
+ </DropzoneDescription>
+ </div>
+ </div>
+ </DropzoneZone>
+ )}
+ </Dropzone>
+
+ {newFileFields.length > 0 && (
+ <div className="flex flex-col gap-2">
+ {newFileFields.map((field, idx) => {
+ const file = form.getValues(`newFiles.${idx}`)
+ if (!file) return null
+ return (
+ <div
+ key={field.id}
+ className="flex items-center justify-between border rounded p-2"
+ >
+ <span className="text-sm">
+ {file.name} ({prettyBytes(file.size)})
+ </span>
+ <Button
+ variant="ghost"
+ size="icon"
+ type="button"
+ onClick={() => remove(idx)}
+ >
+ <X className="h-4 w-4" />
+ </Button>
+ </div>
+ )
+ })}
+ </div>
+ )}
+
+ <SheetFooter className="gap-2 pt-4">
+ <SheetClose asChild>
+ <Button type="button" variant="outline">
+ Cancel
+ </Button>
+ </SheetClose>
+ <Button disabled={isPending}>
+ {isPending && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
+ Save
+ </Button>
+ </SheetFooter>
+ </form>
+ </Form>
+ </SheetContent>
+ </Sheet>
+ )
+} \ No newline at end of file
diff --git a/lib/vendor-rfq-response/vendor-cbe-table/respond-cbe-sheet.tsx b/lib/vendor-rfq-response/vendor-cbe-table/respond-cbe-sheet.tsx
new file mode 100644
index 00000000..8cc4fa6f
--- /dev/null
+++ b/lib/vendor-rfq-response/vendor-cbe-table/respond-cbe-sheet.tsx
@@ -0,0 +1,427 @@
+"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 { z } from "zod"
+
+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 { Input } from "@/components/ui/input"
+import {
+ Sheet,
+ SheetClose,
+ SheetContent,
+ SheetDescription,
+ SheetFooter,
+ SheetHeader,
+ SheetTitle,
+} from "@/components/ui/sheet"
+import { Textarea } from "@/components/ui/textarea"
+import { VendorWithCbeFields } from "@/config/vendorCbeColumnsConfig"
+import { getCommercialResponseByResponseId, updateCommercialResponse } from "../service"
+
+// Define schema for form validation (client-side)
+const commercialResponseFormSchema = z.object({
+ responseStatus: z.enum(["PENDING", "IN_PROGRESS", "SUBMITTED", "REJECTED", "ACCEPTED"]),
+ totalPrice: z.coerce.number().optional(),
+ currency: z.string().default("USD"),
+ paymentTerms: z.string().optional(),
+ incoterms: z.string().optional(),
+ deliveryPeriod: z.string().optional(),
+ warrantyPeriod: z.string().optional(),
+ validityPeriod: z.string().optional(),
+ priceBreakdown: z.string().optional(),
+ commercialNotes: z.string().optional(),
+})
+
+type CommercialResponseFormInput = z.infer<typeof commercialResponseFormSchema>
+
+interface CommercialResponseSheetProps
+ extends React.ComponentPropsWithRef<typeof Sheet> {
+ rfq: VendorWithCbeFields | null
+ responseId: number | null // This is the vendor_responses.id
+ onSuccess?: () => void
+}
+
+export function CommercialResponseSheet({
+ rfq,
+ responseId,
+ onSuccess,
+ ...props
+}: CommercialResponseSheetProps) {
+ const [isSubmitting, startSubmitTransition] = React.useTransition()
+ const [isLoading, setIsLoading] = React.useState(true)
+
+ const form = useForm<CommercialResponseFormInput>({
+ resolver: zodResolver(commercialResponseFormSchema),
+ defaultValues: {
+ responseStatus: "PENDING",
+ totalPrice: undefined,
+ currency: "USD",
+ paymentTerms: "",
+ incoterms: "",
+ deliveryPeriod: "",
+ warrantyPeriod: "",
+ validityPeriod: "",
+ priceBreakdown: "",
+ commercialNotes: "",
+ },
+ })
+
+ // Load existing commercial response data when sheet opens
+ React.useEffect(() => {
+ async function loadCommercialResponse() {
+ if (!responseId) return
+
+ setIsLoading(true)
+ try {
+ // Use the helper function to get existing data
+ const existingResponse = await getCommercialResponseByResponseId(responseId)
+
+ if (existingResponse) {
+ // If we found existing data, populate the form
+ form.reset({
+ responseStatus: existingResponse.responseStatus,
+ totalPrice: existingResponse.totalPrice,
+ currency: existingResponse.currency || "USD",
+ paymentTerms: existingResponse.paymentTerms || "",
+ incoterms: existingResponse.incoterms || "",
+ deliveryPeriod: existingResponse.deliveryPeriod || "",
+ warrantyPeriod: existingResponse.warrantyPeriod || "",
+ validityPeriod: existingResponse.validityPeriod || "",
+ priceBreakdown: existingResponse.priceBreakdown || "",
+ commercialNotes: existingResponse.commercialNotes || "",
+ })
+ } else if (rfq) {
+ // If no existing data but we have rfq data with some values already
+ form.reset({
+ responseStatus: rfq.commercialResponseStatus as any || "PENDING",
+ totalPrice: rfq.totalPrice || undefined,
+ currency: rfq.currency || "USD",
+ paymentTerms: rfq.paymentTerms || "",
+ incoterms: rfq.incoterms || "",
+ deliveryPeriod: rfq.deliveryPeriod || "",
+ warrantyPeriod: rfq.warrantyPeriod || "",
+ validityPeriod: rfq.validityPeriod || "",
+ priceBreakdown: "",
+ commercialNotes: "",
+ })
+ }
+ } catch (error) {
+ console.error("Failed to load commercial response data:", error)
+ toast.error("상업 응답 데이터를 불러오는데 실패했습니다")
+ } finally {
+ setIsLoading(false)
+ }
+ }
+
+ loadCommercialResponse()
+ }, [responseId, rfq, form])
+
+ function onSubmit(formData: CommercialResponseFormInput) {
+ if (!responseId) {
+ toast.error("응답 ID를 찾을 수 없습니다")
+ return
+ }
+
+ if (!rfq?.vendorId) {
+ toast.error("협력업체 ID를 찾을 수 없습니다")
+ return
+ }
+
+ startSubmitTransition(async () => {
+ try {
+ // Pass both responseId and vendorId to the server action
+ const result = await updateCommercialResponse({
+ responseId,
+ vendorId: rfq.vendorId, // Include vendorId for revalidateTag
+ ...formData,
+ })
+
+ if (!result.success) {
+ toast.error(result.error || "응답 제출 중 오류가 발생했습니다")
+ return
+ }
+
+ toast.success("Commercial response successfully submitted")
+ props.onOpenChange?.(false)
+
+ if (onSuccess) {
+ onSuccess()
+ }
+ } catch (error) {
+ console.error("Error submitting response:", error)
+ toast.error("응답 제출 중 오류가 발생했습니다")
+ }
+ })
+ }
+
+ return (
+ <Sheet {...props}>
+ <SheetContent className="flex flex-col gap-6 sm:max-w-md">
+ <SheetHeader className="text-left">
+ <SheetTitle>Commercial Response</SheetTitle>
+ <SheetDescription>
+ {rfq?.rfqCode && <span className="font-medium">{rfq.rfqCode}</span>}
+ <div className="mt-1">Please provide your commercial response for this RFQ</div>
+ </SheetDescription>
+ </SheetHeader>
+
+ {isLoading ? (
+ <div className="flex items-center justify-center py-8">
+ <Loader className="h-8 w-8 animate-spin text-muted-foreground" />
+ </div>
+ ) : (
+ <Form {...form}>
+ <form
+ onSubmit={form.handleSubmit(onSubmit)}
+ className="flex flex-col gap-4 overflow-y-auto max-h-[calc(100vh-200px)] pr-2"
+ >
+ <FormField
+ control={form.control}
+ name="responseStatus"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>Response Status</FormLabel>
+ <Select
+ onValueChange={field.onChange}
+ defaultValue={field.value}
+ >
+ <FormControl>
+ <SelectTrigger className="capitalize">
+ <SelectValue placeholder="Select response status" />
+ </SelectTrigger>
+ </FormControl>
+ <SelectContent>
+ <SelectGroup>
+ <SelectItem value="PENDING">Pending</SelectItem>
+ <SelectItem value="IN_PROGRESS">In Progress</SelectItem>
+ <SelectItem value="SUBMITTED">Submitted</SelectItem>
+ </SelectGroup>
+ </SelectContent>
+ </Select>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+
+ <div className="grid grid-cols-2 gap-4">
+ <FormField
+ control={form.control}
+ name="totalPrice"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>Total Price</FormLabel>
+ <FormControl>
+ <Input
+ type="number"
+ placeholder="0.00"
+ {...field}
+ value={field.value || ''}
+ onChange={(e) => {
+ const value = e.target.value === '' ? undefined : parseFloat(e.target.value);
+ field.onChange(value);
+ }}
+ />
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+
+ <FormField
+ control={form.control}
+ name="currency"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>Currency</FormLabel>
+ <Select
+ onValueChange={field.onChange}
+ defaultValue={field.value}
+ >
+ <FormControl>
+ <SelectTrigger>
+ <SelectValue placeholder="Select currency" />
+ </SelectTrigger>
+ </FormControl>
+ <SelectContent>
+ <SelectGroup>
+ <SelectItem value="USD">USD</SelectItem>
+ <SelectItem value="EUR">EUR</SelectItem>
+ <SelectItem value="GBP">GBP</SelectItem>
+ <SelectItem value="KRW">KRW</SelectItem>
+ <SelectItem value="JPY">JPY</SelectItem>
+ </SelectGroup>
+ </SelectContent>
+ </Select>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+ </div>
+
+ {/* Other form fields remain the same */}
+ <FormField
+ control={form.control}
+ name="paymentTerms"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>Payment Terms</FormLabel>
+ <FormControl>
+ <Input placeholder="e.g. Net 30" {...field} />
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+
+ <FormField
+ control={form.control}
+ name="incoterms"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>Incoterms</FormLabel>
+ <Select
+ onValueChange={field.onChange}
+ defaultValue={field.value || ''}
+ >
+ <FormControl>
+ <SelectTrigger>
+ <SelectValue placeholder="Select incoterms" />
+ </SelectTrigger>
+ </FormControl>
+ <SelectContent>
+ <SelectGroup>
+ <SelectItem value="EXW">EXW (Ex Works)</SelectItem>
+ <SelectItem value="FCA">FCA (Free Carrier)</SelectItem>
+ <SelectItem value="FOB">FOB (Free On Board)</SelectItem>
+ <SelectItem value="CIF">CIF (Cost, Insurance & Freight)</SelectItem>
+ <SelectItem value="DAP">DAP (Delivered At Place)</SelectItem>
+ <SelectItem value="DDP">DDP (Delivered Duty Paid)</SelectItem>
+ </SelectGroup>
+ </SelectContent>
+ </Select>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+
+ <FormField
+ control={form.control}
+ name="deliveryPeriod"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>Delivery Period</FormLabel>
+ <FormControl>
+ <Input placeholder="e.g. 4-6 weeks" {...field} />
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+
+ <FormField
+ control={form.control}
+ name="warrantyPeriod"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>Warranty Period</FormLabel>
+ <FormControl>
+ <Input placeholder="e.g. 12 months" {...field} />
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+
+ <FormField
+ control={form.control}
+ name="validityPeriod"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>Validity Period</FormLabel>
+ <FormControl>
+ <Input placeholder="e.g. 30 days" {...field} />
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+
+ <FormField
+ control={form.control}
+ name="priceBreakdown"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>Price Breakdown (Optional)</FormLabel>
+ <FormControl>
+ <Textarea
+ placeholder="Enter price breakdown details here"
+ className="min-h-[100px]"
+ {...field}
+ />
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+
+ <FormField
+ control={form.control}
+ name="commercialNotes"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>Additional Notes (Optional)</FormLabel>
+ <FormControl>
+ <Textarea
+ placeholder="Any additional comments or notes"
+ className="min-h-[100px]"
+ {...field}
+ />
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+
+ <SheetFooter className="gap-2 pt-4 sm:space-x-0">
+ <SheetClose asChild>
+ <Button type="button" variant="outline">
+ Cancel
+ </Button>
+ </SheetClose>
+ <Button disabled={isSubmitting} type="submit">
+ {isSubmitting && (
+ <Loader
+ className="mr-2 size-4 animate-spin"
+ aria-hidden="true"
+ />
+ )}
+ Submit Response
+ </Button>
+ </SheetFooter>
+ </form>
+ </Form>
+ )}
+ </SheetContent>
+ </Sheet>
+ )
+} \ No newline at end of file
diff --git a/lib/vendor-rfq-response/vendor-cbe-table/rfq-detail-dialog.tsx b/lib/vendor-rfq-response/vendor-cbe-table/rfq-detail-dialog.tsx
new file mode 100644
index 00000000..e9328641
--- /dev/null
+++ b/lib/vendor-rfq-response/vendor-cbe-table/rfq-detail-dialog.tsx
@@ -0,0 +1,89 @@
+"use client"
+
+import * as React from "react"
+import {
+ Dialog,
+ DialogContent,
+ DialogHeader,
+ DialogTitle,
+} from "@/components/ui/dialog"
+import { Badge } from "@/components/ui/badge"
+import { VendorWithCbeFields } from "@/config/vendorCbeColumnsConfig"
+import { RfqItemsTable } from "./rfq-items-table/rfq-items-table"
+import { formatDateTime } from "@/lib/utils"
+import { CalendarClock } from "lucide-react"
+
+interface RfqDeailDialogProps {
+ isOpen: boolean
+ onOpenChange: (open: boolean) => void
+ rfqId: number | null
+ rfq: VendorWithCbeFields | null
+}
+
+export function RfqDeailDialog({
+ isOpen,
+ onOpenChange,
+ rfqId,
+ rfq,
+}: RfqDeailDialogProps) {
+ return (
+ <Dialog open={isOpen} onOpenChange={onOpenChange}>
+ <DialogContent className="max-w-[90wv] sm:max-h-[80vh] overflow-auto" style={{ maxWidth: 1000, height: 480 }}>
+ <DialogHeader>
+ <div className="flex flex-col space-y-2">
+ <DialogTitle>프로젝트: {rfq && rfq.projectName}({rfq && rfq.projectCode}) / RFQ: {rfq && rfq.rfqCode} Detail</DialogTitle>
+ {rfq && (
+ <div className="flex flex-col space-y-3 mt-2">
+ <div className="text-sm text-muted-foreground">
+ <span className="font-medium text-foreground">{rfq.rfqDescription && rfq.rfqDescription}</span>
+ </div>
+
+ {/* 정보를 두 행으로 나누어 표시 */}
+ <div className="flex flex-col space-y-2 sm:space-y-0 sm:flex-row sm:justify-between sm:items-center">
+ {/* 첫 번째 행: 상태 배지 */}
+ <div className="flex items-center flex-wrap gap-2">
+ {rfq.rfqType && (
+ <Badge
+ variant={
+ rfq.rfqType === "BUDGETARY" ? "default" :
+ rfq.rfqType === "PURCHASE" ? "destructive" :
+ rfq.rfqType === "PURCHASE_BUDGETARY" ? "secondary" : "outline"
+ }
+ >
+ RFQ 유형: {rfq.rfqType}
+ </Badge>
+ )}
+
+
+ {rfq.vendorStatus && (
+ <Badge variant="outline">
+ RFQ 상태: {rfq.rfqStatus}
+ </Badge>
+ )}
+
+ </div>
+
+ {/* 두 번째 행: Due Date를 강조 표시 */}
+ {rfq.rfqDueDate && (
+ <div className="flex items-center">
+ <Badge variant="secondary" className="flex gap-1 text-xs py-1 px-3">
+ <CalendarClock className="h-3.5 w-3.5" />
+ <span className="font-semibold">Due Date:</span>
+ <span>{formatDateTime(rfq.rfqDueDate)}</span>
+ </Badge>
+ </div>
+ )}
+ </div>
+ </div>
+ )}
+ </div>
+ </DialogHeader>
+ {rfqId && (
+ <div className="py-4">
+ <RfqItemsTable rfqId={rfqId} />
+ </div>
+ )}
+ </DialogContent>
+ </Dialog>
+ )
+} \ No newline at end of file
diff --git a/lib/vendor-rfq-response/vendor-cbe-table/rfq-items-table/rfq-items-table-column.tsx b/lib/vendor-rfq-response/vendor-cbe-table/rfq-items-table/rfq-items-table-column.tsx
new file mode 100644
index 00000000..bf4ae709
--- /dev/null
+++ b/lib/vendor-rfq-response/vendor-cbe-table/rfq-items-table/rfq-items-table-column.tsx
@@ -0,0 +1,62 @@
+"use client"
+// Because columns rely on React state/hooks for row actions
+
+import * as React from "react"
+import { ColumnDef, Row } from "@tanstack/react-table"
+import { ClientDataTableColumnHeaderSimple } from "@/components/client-data-table/data-table-column-simple-header"
+import { formatDate } from "@/lib/utils"
+import { Checkbox } from "@/components/ui/checkbox"
+import { ItemData } from "./rfq-items-table"
+
+
+/** getColumns: return array of ColumnDef for 'vendors' data */
+export function getColumns(): ColumnDef<ItemData>[] {
+ return [
+
+ // Vendor Name
+ {
+ accessorKey: "itemCode",
+ header: ({ column }) => (
+ <ClientDataTableColumnHeaderSimple column={column} title="Item Code" />
+ ),
+ cell: ({ row }) => row.getValue("itemCode"),
+ },
+
+ // Vendor Code
+ {
+ accessorKey: "description",
+ header: ({ column }) => (
+ <ClientDataTableColumnHeaderSimple column={column} title="Description" />
+ ),
+ cell: ({ row }) => row.getValue("description"),
+ },
+
+ // Status
+ {
+ accessorKey: "quantity",
+ header: ({ column }) => (
+ <ClientDataTableColumnHeaderSimple column={column} title="Quantity" />
+ ),
+ cell: ({ row }) => row.getValue("quantity"),
+ },
+
+
+ // 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/vendor-rfq-response/vendor-cbe-table/rfq-items-table/rfq-items-table.tsx b/lib/vendor-rfq-response/vendor-cbe-table/rfq-items-table/rfq-items-table.tsx
new file mode 100644
index 00000000..c5c67e54
--- /dev/null
+++ b/lib/vendor-rfq-response/vendor-cbe-table/rfq-items-table/rfq-items-table.tsx
@@ -0,0 +1,86 @@
+'use client'
+
+import * as React from "react"
+import { ClientDataTable } from "@/components/client-data-table/data-table"
+import { getColumns } from "./rfq-items-table-column"
+import { DataTableAdvancedFilterField } from "@/types/table"
+import { Loader2 } from "lucide-react"
+import { useToast } from "@/hooks/use-toast"
+import { getItemsByRfqId } from "../../service"
+
+export interface ItemData {
+ id: number
+ itemCode: string
+ description: string | null
+ quantity: number
+ uom: string | null
+ createdAt: Date
+ updatedAt: Date
+}
+
+interface RFQItemsTableProps {
+ rfqId: number
+}
+
+export function RfqItemsTable({ rfqId }: RFQItemsTableProps) {
+ const { toast } = useToast()
+
+ const columns = React.useMemo(
+ () => getColumns(),
+ []
+ )
+
+ const [rfqItems, setRfqItems] = React.useState<ItemData[]>([])
+ const [isLoading, setIsLoading] = React.useState(false)
+
+ React.useEffect(() => {
+ async function loadItems() {
+ setIsLoading(true)
+ try {
+ // Use the correct function name (camelCase)
+ const result = await getItemsByRfqId(rfqId)
+ if (result.success && result.data) {
+ setRfqItems(result.data as ItemData[])
+ } else {
+ throw new Error(result.error || "Unknown error occurred")
+ }
+ } catch (error) {
+ console.error("RFQ 아이템 로드 오류:", error)
+ toast({
+ title: "Error",
+ description: "Failed to load RFQ items",
+ variant: "destructive",
+ })
+ } finally {
+ setIsLoading(false)
+ }
+ }
+ loadItems()
+ }, [toast, rfqId])
+
+ const advancedFilterFields: DataTableAdvancedFilterField<ItemData>[] = [
+ { id: "itemCode", label: "Item Code", type: "text" },
+ { id: "description", label: "Description", type: "text" },
+ { id: "quantity", label: "Quantity", type: "number" },
+ { id: "uom", label: "UoM", type: "text" },
+ ]
+
+ // If loading, show a flex container that fills the parent and centers the spinner
+ if (isLoading) {
+ return (
+ <div className="flex h-full w-full items-center justify-center">
+ <Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
+ </div>
+ )
+ }
+
+ // Otherwise, show the table
+ return (
+ <ClientDataTable
+ data={rfqItems}
+ columns={columns}
+ advancedFilterFields={advancedFilterFields}
+ >
+ </ClientDataTable>
+ )
+} \ No newline at end of file
diff --git a/lib/vendor-rfq-response/vendor-tbe-table/comments-sheet.tsx b/lib/vendor-rfq-response/vendor-tbe-table/comments-sheet.tsx
index 1eee54f5..e0bf9727 100644
--- a/lib/vendor-rfq-response/vendor-tbe-table/comments-sheet.tsx
+++ b/lib/vendor-rfq-response/vendor-tbe-table/comments-sheet.tsx
@@ -4,7 +4,7 @@ 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 { Loader, Download, X, Loader2 } from "lucide-react"
import prettyBytes from "pretty-bytes"
import { toast } from "sonner"
@@ -79,6 +79,8 @@ interface CommentSheetProps extends React.ComponentPropsWithRef<typeof Sheet> {
vendorId:number
/** 댓글 저장 후 갱신용 콜백 (옵션) */
onCommentsUpdated?: (comments: TbeComment[]) => void
+ isLoading?: boolean // New prop
+
}
// 새 코멘트 작성 폼 스키마
@@ -96,6 +98,7 @@ export function CommentSheet({
initialComments = [],
currentUserId,
onCommentsUpdated,
+ isLoading = false, // Default to false
...props
}: CommentSheetProps) {
const [comments, setComments] = React.useState<TbeComment[]>(initialComments)
@@ -125,6 +128,15 @@ export function CommentSheet({
// 간단히 테이블 하나로 표현
// 실제로는 Bubble 형태의 UI, Accordion, Timeline 등 다양하게 구성할 수 있음
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>
}
diff --git a/lib/vendor-rfq-response/vendor-tbe-table/feature-flags-provider.tsx b/lib/vendor-rfq-response/vendor-tbe-table/feature-flags-provider.tsx
deleted file mode 100644
index 81131894..00000000
--- a/lib/vendor-rfq-response/vendor-tbe-table/feature-flags-provider.tsx
+++ /dev/null
@@ -1,108 +0,0 @@
-"use client"
-
-import * as React from "react"
-import { useQueryState } from "nuqs"
-
-import { dataTableConfig, type DataTableConfig } from "@/config/data-table"
-import { cn } from "@/lib/utils"
-import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group"
-import {
- Tooltip,
- TooltipContent,
- TooltipTrigger,
-} from "@/components/ui/tooltip"
-
-type FeatureFlagValue = DataTableConfig["featureFlags"][number]["value"]
-
-interface FeatureFlagsContextProps {
- featureFlags: FeatureFlagValue[]
- setFeatureFlags: (value: FeatureFlagValue[]) => void
-}
-
-const FeatureFlagsContext = React.createContext<FeatureFlagsContextProps>({
- featureFlags: [],
- setFeatureFlags: () => {},
-})
-
-export function useFeatureFlags() {
- const context = React.useContext(FeatureFlagsContext)
- if (!context) {
- throw new Error(
- "useFeatureFlags must be used within a FeatureFlagsProvider"
- )
- }
- return context
-}
-
-interface FeatureFlagsProviderProps {
- children: React.ReactNode
-}
-
-export function FeatureFlagsProvider({ children }: FeatureFlagsProviderProps) {
- const [featureFlags, setFeatureFlags] = useQueryState<FeatureFlagValue[]>(
- "flags",
- {
- defaultValue: [],
- parse: (value) => value.split(",") as FeatureFlagValue[],
- serialize: (value) => value.join(","),
- eq: (a, b) =>
- a.length === b.length && a.every((value, index) => value === b[index]),
- clearOnDefault: true,
- shallow: false,
- }
- )
-
- return (
- <FeatureFlagsContext.Provider
- value={{
- featureFlags,
- setFeatureFlags: (value) => void setFeatureFlags(value),
- }}
- >
- <div className="w-full overflow-x-auto">
- <ToggleGroup
- type="multiple"
- variant="outline"
- size="sm"
- value={featureFlags}
- onValueChange={(value: FeatureFlagValue[]) => setFeatureFlags(value)}
- className="w-fit gap-0"
- >
- {dataTableConfig.featureFlags.map((flag, index) => (
- <Tooltip key={flag.value}>
- <ToggleGroupItem
- value={flag.value}
- className={cn(
- "gap-2 whitespace-nowrap rounded-none px-3 text-xs data-[state=on]:bg-accent/70 data-[state=on]:hover:bg-accent/90",
- {
- "rounded-l-sm border-r-0": index === 0,
- "rounded-r-sm":
- index === dataTableConfig.featureFlags.length - 1,
- }
- )}
- asChild
- >
- <TooltipTrigger>
- <flag.icon className="size-3.5 shrink-0" aria-hidden="true" />
- {flag.label}
- </TooltipTrigger>
- </ToggleGroupItem>
- <TooltipContent
- align="start"
- side="bottom"
- sideOffset={6}
- className="flex max-w-60 flex-col space-y-1.5 border bg-background py-2 font-semibold text-foreground"
- >
- <div>{flag.tooltipTitle}</div>
- <div className="text-xs text-muted-foreground">
- {flag.tooltipDescription}
- </div>
- </TooltipContent>
- </Tooltip>
- ))}
- </ToggleGroup>
- </div>
- {children}
- </FeatureFlagsContext.Provider>
- )
-}
diff --git a/lib/vendor-rfq-response/vendor-tbe-table/rfq-detail-dialog.tsx b/lib/vendor-rfq-response/vendor-tbe-table/rfq-detail-dialog.tsx
new file mode 100644
index 00000000..2056a48f
--- /dev/null
+++ b/lib/vendor-rfq-response/vendor-tbe-table/rfq-detail-dialog.tsx
@@ -0,0 +1,86 @@
+"use client"
+
+import * as React from "react"
+import {
+ Dialog,
+ DialogContent,
+ DialogHeader,
+ DialogTitle,
+} from "@/components/ui/dialog"
+import { Badge } from "@/components/ui/badge"
+import { formatDateTime } from "@/lib/utils"
+import { CalendarClock } from "lucide-react"
+import { RfqItemsTable } from "../vendor-cbe-table/rfq-items-table/rfq-items-table"
+import { TbeVendorFields } from "@/config/vendorTbeColumnsConfig"
+
+interface RfqDeailDialogProps {
+ isOpen: boolean
+ onOpenChange: (open: boolean) => void
+ rfqId: number | null
+ rfq: TbeVendorFields | null
+}
+
+export function RfqDeailDialog({
+ isOpen,
+ onOpenChange,
+ rfqId,
+ rfq,
+}: RfqDeailDialogProps) {
+ return (
+ <Dialog open={isOpen} onOpenChange={onOpenChange}>
+ <DialogContent className="max-w-[90wv] sm:max-h-[80vh] overflow-auto" style={{maxWidth:1000, height:480}}>
+ <DialogHeader>
+ <div className="flex flex-col space-y-2">
+ <DialogTitle>{rfq && rfq.rfqCode} Detail</DialogTitle>
+ {rfq && (
+ <div className="flex flex-col space-y-3 mt-2">
+ <div className="text-sm text-muted-foreground">
+ <span className="font-medium text-foreground">{rfq.rfqDescription && rfq.rfqDescription}</span>
+ </div>
+
+ {/* 정보를 두 행으로 나누어 표시 */}
+ <div className="flex flex-col space-y-2 sm:space-y-0 sm:flex-row sm:justify-between sm:items-center">
+ {/* 첫 번째 행: 상태 배지 */}
+ <div className="flex items-center flex-wrap gap-2">
+ {rfq.vendorStatus && (
+ <Badge variant="outline">
+ {rfq.rfqStatus}
+ </Badge>
+ )}
+ {rfq.rfqType && (
+ <Badge
+ variant={
+ rfq.rfqType === "BUDGETARY" ? "default" :
+ rfq.rfqType === "PURCHASE" ? "destructive" :
+ rfq.rfqType === "PURCHASE_BUDGETARY" ? "secondary" : "outline"
+ }
+ >
+ {rfq.rfqType}
+ </Badge>
+ )}
+ </div>
+
+ {/* 두 번째 행: Due Date를 강조 표시 */}
+ {rfq.rfqDueDate && (
+ <div className="flex items-center">
+ <Badge variant="secondary" className="flex gap-1 text-xs py-1 px-3">
+ <CalendarClock className="h-3.5 w-3.5" />
+ <span className="font-semibold">Due Date:</span>
+ <span>{formatDateTime(rfq.rfqDueDate)}</span>
+ </Badge>
+ </div>
+ )}
+ </div>
+ </div>
+ )}
+ </div>
+ </DialogHeader>
+ {rfqId && (
+ <div className="py-4">
+ <RfqItemsTable rfqId={rfqId} />
+ </div>
+ )}
+ </DialogContent>
+ </Dialog>
+ )
+} \ No newline at end of file
diff --git a/lib/vendor-rfq-response/vendor-tbe-table/tbe-table-columns.tsx b/lib/vendor-rfq-response/vendor-tbe-table/tbe-table-columns.tsx
index 7a95d7ed..f664d9a3 100644
--- a/lib/vendor-rfq-response/vendor-tbe-table/tbe-table-columns.tsx
+++ b/lib/vendor-rfq-response/vendor-tbe-table/tbe-table-columns.tsx
@@ -31,6 +31,8 @@ interface GetColumnsProps {
openCommentSheet: (vendorId: number) => void
handleDownloadTbeTemplate: (tbeId: number, vendorId: number, rfqId: number) => void
handleUploadTbeResponse: (tbeId: number, vendorId: number, rfqId: number, vendorResponseId:number) => void
+ openVendorContactsDialog: (rfqId: number, rfq: TbeVendorFields) => void // 수정된 시그니처
+
}
/**
@@ -42,6 +44,7 @@ export function getColumns({
openCommentSheet,
handleDownloadTbeTemplate,
handleUploadTbeResponse,
+ openVendorContactsDialog
}: GetColumnsProps): ColumnDef<TbeVendorFields>[] {
// ----------------------------------------------------------------
// 1) Select 컬럼 (체크박스)
@@ -112,7 +115,30 @@ export function getColumns({
)
}
-
+
+ if (cfg.id === "rfqCode") {
+ const rfq = row.original;
+ const rfqId = rfq.rfqId;
+
+ // 협력업체 이름을 클릭할 수 있는 버튼으로 렌더링
+ const handleVendorNameClick = () => {
+ if (rfqId) {
+ openVendorContactsDialog(rfqId, rfq); // vendor 전체 객체 전달
+ } else {
+ toast.error("협력업체 ID를 찾을 수 없습니다.");
+ }
+ };
+
+ return (
+ <Button
+ variant="link"
+ className="p-0 h-auto text-left font-normal justify-start hover:underline"
+ onClick={handleVendorNameClick}
+ >
+ {val as string}
+ </Button>
+ );
+ }
if (cfg.id === "rfqVendorStatus") {
const statusVal = row.original.rfqVendorStatus
if (!statusVal) return null
@@ -173,21 +199,28 @@ export function getColumns({
}
return (
- <div>
- <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">
- <MessageSquare className="h-4 w-4 text-muted-foreground group-hover:text-primary transition-colors" />
- </div>
- {commCount > 0 && <span className="absolute -top-1 -right-1 inline-flex h-2 w-2 rounded-full bg-red-500"></span>}
- <span className="sr-only">{commCount > 0 ? `${commCount} Comments` : "Add Comment"}</span>
- </Button>
- </div>
+ <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,
diff --git a/lib/vendor-rfq-response/vendor-tbe-table/tbe-table.tsx b/lib/vendor-rfq-response/vendor-tbe-table/tbe-table.tsx
index 3450a643..13d5dc64 100644
--- a/lib/vendor-rfq-response/vendor-tbe-table/tbe-table.tsx
+++ b/lib/vendor-rfq-response/vendor-tbe-table/tbe-table.tsx
@@ -7,19 +7,17 @@ import type {
DataTableFilterField,
DataTableRowAction,
} from "@/types/table"
-
-import { toSentenceCase } from "@/lib/utils"
+import { toast } from "sonner"
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 { fetchRfqAttachmentsbyCommentId, getTBEforVendor } from "../../rfqs/service"
import { CommentSheet, TbeComment } from "./comments-sheet"
import { TbeVendorFields } from "@/config/vendorTbeColumnsConfig"
import { useTbeFileHandlers } from "./tbeFileHandler"
import { useSession } from "next-auth/react"
+import { RfqDeailDialog } from "./rfq-detail-dialog"
interface VendorsTableProps {
promises: Promise<
@@ -30,7 +28,6 @@ interface VendorsTableProps {
}
export function TbeVendorTable({ promises }: VendorsTableProps) {
- const { featureFlags } = useFeatureFlags()
const { data: session } = useSession()
const userVendorId = session?.user?.companyId
const userId = Number(session?.user?.id)
@@ -43,8 +40,20 @@ export function TbeVendorTable({ promises }: VendorsTableProps) {
const router = useRouter()
const [initialComments, setInitialComments] = React.useState<TbeComment[]>([])
+ const [isLoadingComments, setIsLoadingComments] = React.useState(false)
+
const [commentSheetOpen, setCommentSheetOpen] = React.useState(false)
const [selectedRfqIdForComments, setSelectedRfqIdForComments] = React.useState<number | null>(null)
+ const [isRfqDetailDialogOpen, setIsRfqDetailDialogOpen] = React.useState(false)
+
+ const [selectedRfqId, setSelectedRfqId] = React.useState<number | null>(null)
+ const [selectedRfq, setSelectedRfq] = React.useState<TbeVendorFields | null>(null)
+
+ const openVendorContactsDialog = (rfqId: number, rfq: TbeVendorFields) => {
+ setSelectedRfqId(rfqId)
+ setSelectedRfq(rfq)
+ setIsRfqDetailDialogOpen(true)
+ }
// TBE 파일 핸들러 훅 사용
const {
@@ -62,9 +71,11 @@ export function TbeVendorTable({ promises }: VendorsTableProps) {
async function openCommentSheet(vendorId: number) {
setInitialComments([])
+ setIsLoadingComments(true)
const comments = rowAction?.row.original.comments
+ try {
if (comments && comments.length > 0) {
const commentWithAttachments: TbeComment[] = await Promise.all(
comments.map(async (c) => {
@@ -73,18 +84,26 @@ export function TbeVendorTable({ promises }: VendorsTableProps) {
return {
...c,
- commentedBy: 1, // DB나 API 응답에 있다고 가정
+ commentedBy: userId, // DB나 API 응답에 있다고 가정
attachments,
}
})
)
-
+
setInitialComments(commentWithAttachments)
}
setSelectedRfqIdForComments(vendorId)
setCommentSheetOpen(true)
+
+ } catch (error) {
+ console.error("Error loading comments:", error)
+ toast.error("Failed to load comments")
+ } finally {
+ // End loading regardless of success/failure
+ setIsLoadingComments(false)
}
+}
// getColumns() 호출 시, 필요한 모든 핸들러 함수 주입
const columns = React.useMemo(
@@ -94,27 +113,25 @@ export function TbeVendorTable({ promises }: VendorsTableProps) {
openCommentSheet,
handleDownloadTbeTemplate,
handleUploadTbeResponse,
+ openVendorContactsDialog
}),
- [setRowAction, router, openCommentSheet, handleDownloadTbeTemplate, handleUploadTbeResponse]
+ [setRowAction, router, openCommentSheet, handleDownloadTbeTemplate, handleUploadTbeResponse, openVendorContactsDialog]
)
const filterFields: DataTableFilterField<TbeVendorFields>[] = []
const advancedFilterFields: DataTableAdvancedFilterField<TbeVendorFields>[] = [
- { 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: "rfqCode", label: "RFQ Code", type: "text" },
+ { id: "projectCode", label: "Project Code", type: "text" },
+ { id: "projectName", label: "Project Name", type: "text" },
+ { id: "rfqCode", label: "RFQ Code", type: "text" },
+ { id: "tbeResult", label: "TBE Result", type: "text" },
+ { id: "tbeNote", label: "TBE Note", type: "text" },
+ { id: "rfqCode", label: "RFQ Code", type: "text" },
+ { id: "hasResponse", label: "Response?", type: "boolean" },
{ id: "rfqVendorUpdated", label: "Updated at", type: "date" },
+ { id: "dueDate", label: "Project Name", type: "date" },
+
]
const { table } = useDataTable({
@@ -150,11 +167,20 @@ export function TbeVendorTable({ promises }: VendorsTableProps) {
onOpenChange={setCommentSheetOpen}
rfqId={selectedRfqIdForComments}
initialComments={initialComments}
- vendorId={userVendorId||0}
- currentUserId={userId||0}
+ vendorId={userVendorId || 0}
+ currentUserId={userId || 0}
+ isLoading={isLoadingComments} // Pass the loading state
+
/>
)}
+ <RfqDeailDialog
+ isOpen={isRfqDetailDialogOpen}
+ onOpenChange={setIsRfqDetailDialogOpen}
+ rfqId={selectedRfqId}
+ rfq={selectedRfq}
+ />
+
{/* TBE 파일 다이얼로그 */}
<UploadDialog />
</>
diff --git a/lib/vendor-rfq-response/vendor-tbe-table/tbeFileHandler.tsx b/lib/vendor-rfq-response/vendor-tbe-table/tbeFileHandler.tsx
index 4efaee77..a0b6f805 100644
--- a/lib/vendor-rfq-response/vendor-tbe-table/tbeFileHandler.tsx
+++ b/lib/vendor-rfq-response/vendor-tbe-table/tbeFileHandler.tsx
@@ -13,9 +13,9 @@ import {
import { Button } from "@/components/ui/button";
import {
fetchTbeTemplateFiles,
- getTbeTemplateFileInfo,
uploadTbeResponseFile,
getTbeSubmittedFiles,
+ getFileFromRfqAttachmentsbyid,
} from "../../rfqs/service";
import {
Dropzone,
@@ -118,7 +118,7 @@ export function useTbeFileHandlers() {
// 실제 다운로드 로직
const downloadFile = useCallback(async (fileId: number) => {
try {
- const { file, error } = await getTbeTemplateFileInfo(fileId);
+ const { file, error } = await getFileFromRfqAttachmentsbyid(fileId);
if (error || !file) {
throw new Error(error || "파일 정보를 가져오는 데 실패했습니다");
}