diff options
Diffstat (limited to 'lib')
| -rw-r--r-- | lib/po/service.ts | 5 | ||||
| -rw-r--r-- | lib/po/table/po-table-columns.tsx | 95 | ||||
| -rw-r--r-- | lib/rfqs/cbe-table/cbe-table.tsx | 6 | ||||
| -rw-r--r-- | lib/rfqs/service.ts | 68 | ||||
| -rw-r--r-- | lib/rfqs/table/rfqs-table.tsx | 4 | ||||
| -rw-r--r-- | lib/rfqs/tbe-table/file-dialog.tsx | 2 | ||||
| -rw-r--r-- | lib/rfqs/tbe-table/tbe-table-columns.tsx | 43 | ||||
| -rw-r--r-- | lib/rfqs/tbe-table/tbe-table.tsx | 6 | ||||
| -rw-r--r-- | lib/rfqs/vendor-table/comments-sheet.tsx | 25 | ||||
| -rw-r--r-- | lib/rfqs/vendor-table/vendors-table-columns.tsx | 120 | ||||
| -rw-r--r-- | lib/rfqs/vendor-table/vendors-table.tsx | 77 | ||||
| -rw-r--r-- | lib/vendor-rfq-response/vendor-rfq-table/comments-sheet.tsx | 241 | ||||
| -rw-r--r-- | lib/vendor-rfq-response/vendor-rfq-table/rfqs-table.tsx | 24 | ||||
| -rw-r--r-- | lib/vendor-rfq-response/vendor-tbe-table/tbe-table-columns.tsx | 2 | ||||
| -rw-r--r-- | lib/vendor-rfq-response/vendor-tbe-table/tbeFileHandler.tsx | 2 | ||||
| -rw-r--r-- | lib/vendors/table/request-vendor-pg-dialog.tsx | 4 |
16 files changed, 395 insertions, 329 deletions
diff --git a/lib/po/service.ts b/lib/po/service.ts index 0653c11a..f697bd58 100644 --- a/lib/po/service.ts +++ b/lib/po/service.ts @@ -324,6 +324,7 @@ Remarks:${contract.remarks}`, const { success: sendDocuSignResult, envelopeId } = sendDocuSign; + await tx .update(contracts) .set({ @@ -343,8 +344,7 @@ Remarks:${contract.remarks}`, const fileName = `${contractNo}-signature.pdf`; const ext = path.extname(fileName); const uniqueName = uuidv4() + ext; - - // Create a single envelope for all signers + // Create a single envelope for all signers const [newEnvelope] = await tx .insert(contractEnvelopes) .values({ @@ -352,6 +352,7 @@ Remarks:${contract.remarks}`, envelopeId: envelopeId, envelopeStatus: "sent", fileName: `${contractNo}-signature.pdf`, // Required field + filePath: `/contracts/${validatedData.contractId}/signatures/${uniqueName}`, // Required field // Add any other required fields based on your schema }) diff --git a/lib/po/table/po-table-columns.tsx b/lib/po/table/po-table-columns.tsx index c2c01136..a13b2acf 100644 --- a/lib/po/table/po-table-columns.tsx +++ b/lib/po/table/po-table-columns.tsx @@ -91,16 +91,77 @@ export function getColumns({ setRowAction }: GetColumnsProps): ColumnDef<Contrac // 3-1) groupMap: { [groupName]: ColumnDef<ContractDetail>[] } const groupMap: Record<string, ColumnDef<ContractDetail>[]> = {}; - poColumnsConfig.forEach((cfg) => { - // Use "_noGroup" if no group is specified - const groupName = cfg.group || "_noGroup"; + // (1) JSON config를 읽어서 ColumnDef를 생성하는 부분 (일부 발췌) +poColumnsConfig.forEach((cfg) => { + const groupName = cfg.group || "_noGroup" + if (!groupMap[groupName]) { + groupMap[groupName] = [] + } - if (!groupMap[groupName]) { - groupMap[groupName] = []; - } + let childCol: ColumnDef<ContractDetail> + + if (cfg.type === "custom" && cfg.customType === "esignStatus") { + // ======================================== + // (2) 전자서명 전용 커스텀 컬럼 + // ======================================== + childCol = { + id: cfg.id, + header: ({ column }) => ( + <DataTableColumnHeaderSimple column={column} title={cfg.label} /> + ), + // 여기서 row.original.envelopes 등 활용하여 최신 전자서명 상태 표시 + cell: ({ row }) => { + const data = row.original + if (!data.envelopes || data.envelopes.length === 0) { + return ( + <div className="text-sm text-gray-500"> + No E-Sign + </div> + ) + } + + // envelopes가 여러 개 있으면 최신(가장 최근 updatedAt) 가져오기 + const sorted = [...data.envelopes].sort((a, b) => { + const dateA = new Date(a.updatedAt) + const dateB = new Date(b.updatedAt) + return dateB.getTime() - dateA.getTime() + }) + const latest = sorted[0] - // Child column definition - const childCol: ColumnDef<ContractDetail> = { + // 상태에 따라 다른 UI 색상/아이콘 + const status = latest.envelopeStatus // "sent", "completed", ... + const colorMap: Record<string, string> = { + completed: "text-green-600", + sent: "text-blue-600", + voided: "text-red-600", + // ... + } + const colorClass = colorMap[status] || "text-gray-700" + + return ( + <Button + onClick={() => { + // 다이얼로그 열기 등 + // 예: setRowAction({ row, type: "esign-detail" }) + setRowAction({ row, type: "esign-detail" }) + }} + className={`underline underline-offset-2 ${colorClass}`} + > + {status} + </Button> + ) + }, + meta: { + excelHeader: cfg.excelHeader, + group: cfg.group, + type: cfg.type, + }, + } + } else { + // ======================================== + // (3) 일반 컬럼 (type: text/date/number 등) + // ======================================== + childCol = { accessorKey: cfg.id, enableResizing: true, header: ({ column }) => ( @@ -112,17 +173,19 @@ export function getColumns({ setRowAction }: GetColumnsProps): ColumnDef<Contrac type: cfg.type, }, cell: ({ row, cell }) => { - if (cfg.id === "createdAt" || cfg.id === "updatedAt") { - const dateVal = cell.getValue() as Date; - return formatDate(dateVal); + // 날짜 포맷, 숫자 포맷 등 처리 + if (cfg.type === "date") { + const dateVal = cell.getValue() as Date + return formatDate(dateVal) } - - return row.getValue(cfg.id) ?? ""; + // ... + return row.getValue(cfg.id) ?? "" }, - }; + } + } - groupMap[groupName].push(childCol); - }); + groupMap[groupName].push(childCol) +}) // ---------------------------------------------------------------- // 3-2) Create actual parent columns (groups) from the groupMap diff --git a/lib/rfqs/cbe-table/cbe-table.tsx b/lib/rfqs/cbe-table/cbe-table.tsx index 243b91ed..b2a74466 100644 --- a/lib/rfqs/cbe-table/cbe-table.tsx +++ b/lib/rfqs/cbe-table/cbe-table.tsx @@ -144,8 +144,11 @@ export function CbeTable({ promises, rfqId }: VendorsTableProps) { return ( <> - <DataTable +<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} @@ -155,6 +158,7 @@ export function CbeTable({ promises, rfqId }: VendorsTableProps) { {/* <VendorsTableToolbarActions table={table} rfqId={rfqId} /> */} </DataTableAdvancedToolbar> </DataTable> + </div> </> ) diff --git a/lib/rfqs/service.ts b/lib/rfqs/service.ts index b1e02cd0..6b8b4738 100644 --- a/lib/rfqs/service.ts +++ b/lib/rfqs/service.ts @@ -24,6 +24,7 @@ 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 { @@ -116,7 +117,7 @@ export async function getRfqs(input: GetRfqsSchema) { [JSON.stringify(input)], { revalidate: 3600, - tags: [`rfqs-${input.rfqType}`], + tags: [`rfqs-${input.rfqType}`], } )(); } @@ -597,8 +598,6 @@ export async function getMatchedVendors(input: GetMatchedVendorsSchema, rfqId: n return { data: [], pageCount: 0 } } - console.log(vendorIdList,"vendorIdList") - // ───────────────────────────────────────────────────── // 3) 필터/검색/정렬 // ───────────────────────────────────────────────────── @@ -631,7 +630,9 @@ export async function getMatchedVendors(input: GetMatchedVendorsSchema, rfqId: n // 특정 rfqId(뷰에 담긴 값)도 일치해야 함. const finalWhere = and( inArray(vendorRfqView.vendorId, vendorIdList), - eq(vendorRfqView.rfqId, rfqId), + // 아래 라인은 rfq에 초대된 벤더만 필터링하는 조건으로 추정되지만 + // rfq 를 진행하기 전에도 벤더를 보여줘야 하므로 주석처리하겠습니다 + // eq(vendorRfqView.rfqId, rfqId), advancedWhere, globalWhere ) @@ -670,18 +671,21 @@ export async function getMatchedVendors(input: GetMatchedVendorsSchema, rfqId: n .offset(offset) .limit(limit) - // 총 개수 + // 중복 제거된 데이터 생성 + const distinctData = Array.from( + new Map(data.map(row => [row.id, row])).values() + ) + + // 중복 제거된 총 개수 계산 const [{ count }] = await tx - .select({ count: sql<number>`count(*)`.as("count") }) + .select({ count: sql<number>`count(DISTINCT ${vendorRfqView.vendorId})`.as("count") }) .from(vendorRfqView) .where(finalWhere) - return [data, Number(count)] + return [distinctData, Number(count)] }) - console.log(rows) - console.log(total) // ───────────────────────────────────────────────────── // 4-1) 정확한 rfqVendorStatus와 rfqVendorUpdated 조회 // ───────────────────────────────────────────────────── @@ -732,11 +736,36 @@ export async function getMatchedVendors(input: GetMatchedVendorsSchema, rfqId: n ) 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, @@ -744,9 +773,9 @@ export async function getMatchedVendors(input: GetMatchedVendorsSchema, rfqId: n evaluationId: c.evaluationId, createdAt: c.createdAt, commentedBy: c.commentedBy, + commentedByEmail: userEmail, // 이메일 추가 }) } - // ───────────────────────────────────────────────────── // 6) rows에 comments 병합 // ───────────────────────────────────────────────────── @@ -1623,9 +1652,10 @@ export async function createRfqCommentWithAttachments(params: { commentText: string commentedBy: number evaluationId?: number | null + cbeId?: number | null files?: File[] }) { - const { rfqId, vendorId, commentText, commentedBy, evaluationId, files } = params + const { rfqId, vendorId, commentText, commentedBy, evaluationId,cbeId, files } = params // 1) 새로운 코멘트 생성 @@ -1637,6 +1667,7 @@ export async function createRfqCommentWithAttachments(params: { commentText, commentedBy, evaluationId: evaluationId || null, + cbeId: cbeId || null, }) .returning({ id: rfqComments.id, createdAt: rfqComments.createdAt }) // id만 반환하도록 @@ -1644,7 +1675,7 @@ export async function createRfqCommentWithAttachments(params: { throw new Error("Failed to create comment") } - // 2) 첨부파일 처리 (S3 업로드 등은 프로젝트 상황에 따라) + // 2) 첨부파일 처리 if (files && files.length > 0) { const rfqDir = path.join(process.cwd(), "public", "rfq", String(rfqId)); @@ -1669,6 +1700,7 @@ export async function createRfqCommentWithAttachments(params: { rfqId, vendorId: vendorId || null, evaluationId: evaluationId || null, + cbeId: cbeId || null, commentId: insertedComment.id, // 새 코멘트와 연결 fileName: file.name, filePath: "/" + relativePath.replace(/\\/g, "/"), @@ -2552,10 +2584,10 @@ export async function getCBE(input: GetCBESchema, rfqId: number) { // [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); - }) + // vendor_cbe_view 컬럼 중 정렬 대상이 되는 것만 매핑 + const col = (vendorCbeView as any)[s.id]; + return s.desc ? desc(col) : asc(col); + }) : [asc(vendorCbeView.vendorId)]; // [7] 메인 SELECT @@ -2690,7 +2722,7 @@ export async function getCBE(input: GetCBESchema, rfqId: number) { // Step 2: responseIds const allResponseIds = responsesAll.map((r) => r.id); - + const commercialResponsesAll = await db .select({ id: vendorCommercialResponses.id, @@ -2708,7 +2740,7 @@ export async function getCBE(input: GetCBESchema, rfqId: number) { } const allCommercialResponseIds = commercialResponsesAll.map((cr) => cr.id); - + // 여기서는 예시로 TBE와 마찬가지로 vendorResponseAttachments를 // 직접 responseId로 관리한다고 가정(혹은 commercialResponseId로 연결) diff --git a/lib/rfqs/table/rfqs-table.tsx b/lib/rfqs/table/rfqs-table.tsx index db5c31e7..48c04930 100644 --- a/lib/rfqs/table/rfqs-table.tsx +++ b/lib/rfqs/table/rfqs-table.tsx @@ -213,7 +213,7 @@ export function RfqsTable({ promises, rfqType = RfqType.PURCHASE }: RfqsTablePro }) return ( - <> + <div style={{ maxWidth: '100vw' }}> <DataTable table={table} floatingBar={<RfqsTableFloatingBar table={table} />} @@ -259,6 +259,6 @@ export function RfqsTable({ promises, rfqType = RfqType.PURCHASE }: RfqsTablePro rfq={selectedRfq ?? null} onAttachmentsUpdated={handleAttachmentsUpdated} /> - </> + </div> ) }
\ No newline at end of file diff --git a/lib/rfqs/tbe-table/file-dialog.tsx b/lib/rfqs/tbe-table/file-dialog.tsx index 1d1a65ea..772eb930 100644 --- a/lib/rfqs/tbe-table/file-dialog.tsx +++ b/lib/rfqs/tbe-table/file-dialog.tsx @@ -76,7 +76,7 @@ export function TBEFileDialog({ // Download submitted file const downloadSubmittedFile = async (file: any) => { try { - const response = await fetch(`/api/file/${file.id}/download`) + const response = await fetch(`/api/tbe-download?path=${encodeURIComponent(file.filePath)}`) if (!response.ok) { throw new Error("Failed to download file") } diff --git a/lib/rfqs/tbe-table/tbe-table-columns.tsx b/lib/rfqs/tbe-table/tbe-table-columns.tsx index 29fbd5cd..0e9b7064 100644 --- a/lib/rfqs/tbe-table/tbe-table-columns.tsx +++ b/lib/rfqs/tbe-table/tbe-table-columns.tsx @@ -178,37 +178,30 @@ const commentsColumn: ColumnDef<VendorWithTbeFields> = { openCommentSheet(vendor.tbeId ?? 0) } - return ( - <div className="flex items-center justify-center"> + return ( <Button variant="ghost" size="sm" - className="h-8 w-8 p-0 group relative" + className="relative h-8 w-8 p-0 group" onClick={handleClick} - aria-label={commCount > 0 ? `View ${commCount} comments` : "Add comment"} + aria-label={ + commCount > 0 ? `View ${commCount} comments` : "No comments" + } > - <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> + <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> - {/* <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 diff --git a/lib/rfqs/tbe-table/tbe-table.tsx b/lib/rfqs/tbe-table/tbe-table.tsx index c385ca0b..41eff0dc 100644 --- a/lib/rfqs/tbe-table/tbe-table.tsx +++ b/lib/rfqs/tbe-table/tbe-table.tsx @@ -149,10 +149,10 @@ export function TbeTable({ promises, rfqId }: VendorsTableProps) { }) return ( - <> +<div style={{ maxWidth: '80vw' }}> <DataTable table={table} - > + > <DataTableAdvancedToolbar table={table} filterFields={advancedFilterFields} @@ -185,6 +185,6 @@ export function TbeTable({ promises, rfqId }: VendorsTableProps) { 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/vendor-table/comments-sheet.tsx b/lib/rfqs/vendor-table/comments-sheet.tsx index 644869c6..3a2a9353 100644 --- a/lib/rfqs/vendor-table/comments-sheet.tsx +++ b/lib/rfqs/vendor-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 { Download, X, Loader2 } from "lucide-react" import prettyBytes from "pretty-bytes" import { toast } from "sonner" @@ -52,6 +52,7 @@ export interface MatchedVendorComment { id: number commentText: string commentedBy?: number + commentedByEmail?: string createdAt?: Date attachments?: { id: number @@ -67,6 +68,7 @@ interface CommentSheetProps extends React.ComponentPropsWithRef<typeof Sheet> { rfqId: number vendorId: number onCommentsUpdated?: (comments: MatchedVendorComment[]) => void + isLoading?: boolean // New prop } // 2) 폼 스키마 @@ -84,8 +86,12 @@ export function CommentSheet({ 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() @@ -108,6 +114,16 @@ export function CommentSheet({ // (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> } @@ -134,7 +150,7 @@ export function CommentSheet({ {c.attachments.map((att) => ( <div key={att.id} className="flex items-center gap-2"> <a - href={att.filePath} + href={`/api/rfq-download?path=${encodeURIComponent(att.filePath)}`} download target="_blank" rel="noreferrer" @@ -149,7 +165,7 @@ export function CommentSheet({ )} </TableCell> <TableCell> { c.createdAt ? formatDate(c.createdAt): "-"}</TableCell> - <TableCell>{c.commentedBy ?? "-"}</TableCell> + <TableCell>{c.commentedByEmail ?? "-"}</TableCell> </TableRow> ))} </TableBody> @@ -173,6 +189,7 @@ export function CommentSheet({ commentText: data.commentText, commentedBy: currentUserId, evaluationId: null, + cbeId: null, files: data.newFiles, }) @@ -291,7 +308,7 @@ export function CommentSheet({ </Button> </SheetClose> <Button disabled={isPending}> - {isPending && <Loader className="mr-2 h-4 w-4 animate-spin" />} + {isPending && <Loader2 className="mr-2 h-4 w-4 animate-spin" />} Save </Button> </SheetFooter> diff --git a/lib/rfqs/vendor-table/vendors-table-columns.tsx b/lib/rfqs/vendor-table/vendors-table-columns.tsx index 1220cb9d..f152cec5 100644 --- a/lib/rfqs/vendor-table/vendors-table-columns.tsx +++ b/lib/rfqs/vendor-table/vendors-table-columns.tsx @@ -3,7 +3,7 @@ import * as React from "react" import { type DataTableRowAction } from "@/types/table" import { type ColumnDef } from "@tanstack/react-table" -import { Ellipsis } from "lucide-react" +import { Ellipsis, MessageSquare } from "lucide-react" import { toast } from "sonner" import { getErrorMessage } from "@/lib/handle-error" @@ -78,10 +78,6 @@ export function getColumns({ setRowAction, router, openCommentSheet }: GetColumn } // ---------------------------------------------------------------- - // 2) actions 컬럼 (Dropdown 메뉴) - // ---------------------------------------------------------------- - - // ---------------------------------------------------------------- // 3) 일반 컬럼들을 "그룹"별로 묶어 중첩 columns 생성 // ---------------------------------------------------------------- // 3-1) groupMap: { [groupName]: ColumnDef<MatchedVendorRow>[] } @@ -149,19 +145,76 @@ export function getColumns({ setRowAction, router, openCommentSheet }: GetColumn 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", - // header: "Actions", cell: ({ row }) => { const rfq = row.original - const commCount = rfq.comments?.length ?? 0 const status = row.original.rfqVendorStatus + const isDisabled = !status || status === 'INVITED' || status === 'ACCEPTED' - // 공통 코멘트 핸들러 - function handleCommentClick() { - setRowAction({ row, type: "comments" }) - openCommentSheet(Number(row.original.id)) + 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> @@ -175,21 +228,12 @@ export function getColumns({ setRowAction, router, openCommentSheet }: GetColumn </Button> </DropdownMenuTrigger> <DropdownMenuContent align="end" className="w-40"> - {/* 기존 기능: status가 INVITED일 때만 표시 */} {(!status || status === 'INVITED') && ( <DropdownMenuItem onSelect={() => setRowAction({ row, type: "invite" })}> 발행하기 </DropdownMenuItem> )} - {/* 두 기능 사이 구분선 */} - <DropdownMenuSeparator /> - {/* 코멘트 메뉴 항목 */} - <DropdownMenuItem onSelect={handleCommentClick}> - {commCount > 0 ? `${commCount} Comments` : "Add Comment"} - </DropdownMenuItem> - - </DropdownMenuContent> </DropdownMenu> ) @@ -199,37 +243,6 @@ export function getColumns({ setRowAction, router, openCommentSheet }: GetColumn enableHiding: false, } - - // const commentsColumn: ColumnDef<MatchedVendorRow> = { - // id: "comments", - // header: "Comments", - // cell: ({ row }) => { - // const rfq = row.original - // const commCount = rfq.comments?.length ?? 0 - - // // 공통 클릭 핸들러 - // function handleClick() { - // setRowAction({ row, type: "comments" }) - // openCommentSheet(Number(row.original.id)) - // } - - // return commCount > 0 ? ( - // <a - // href="#" - // onClick={(e) => { - // e.preventDefault() - // handleClick() - // }} - // > - // {commCount} Comments - // </a> - // ) : ( - // <Button size="sm" variant="outline" onClick={handleClick}> - // Add Comment - // </Button> - // ) - // }, - // } // ---------------------------------------------------------------- // 3-2) groupMap에서 실제 상위 컬럼(그룹)을 만들기 // ---------------------------------------------------------------- @@ -252,13 +265,12 @@ export function getColumns({ setRowAction, router, openCommentSheet }: GetColumn }) // ---------------------------------------------------------------- - // 4) 최종 컬럼 배열: select, nestedColumns, actions + // 4) 최종 컬럼 배열: select, nestedColumns, comments, actions // ---------------------------------------------------------------- return [ selectColumn, ...nestedColumns, - // commentsColumn, + commentsColumn, actionsColumn - ] }
\ No newline at end of file diff --git a/lib/rfqs/vendor-table/vendors-table.tsx b/lib/rfqs/vendor-table/vendors-table.tsx index 838342bf..ae9cba41 100644 --- a/lib/rfqs/vendor-table/vendors-table.tsx +++ b/lib/rfqs/vendor-table/vendors-table.tsx @@ -2,6 +2,7 @@ import * as React from "react" import { useRouter } from "next/navigation" +import { useSession } from "next-auth/react" // Next-auth session hook 추가 import type { DataTableAdvancedFilterField, DataTableFilterField, @@ -22,6 +23,7 @@ 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>>]> @@ -29,8 +31,11 @@ interface VendorsTableProps { rfqType: RfqType } -export function MatchedVendorsTable({ promises, rfqId, rfqType}: VendorsTableProps) { +export function MatchedVendorsTable({ promises, rfqId, rfqType }: VendorsTableProps) { const { featureFlags } = useFeatureFlags() + const { data: session } = useSession() // 세션 정보 가져오기 + + // 1) Suspense로 받아온 데이터 const [{ data, pageCount }] = React.use(promises) @@ -47,10 +52,13 @@ export function MatchedVendorsTable({ promises, rfqId, rfqType}: VendorsTablePr const router = useRouter() // 3) CommentSheet 에 넣을 상태 - // => “댓글”은 MatchedVendorComment[] 로 관리해야 함 + // => "댓글"은 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) @@ -64,29 +72,42 @@ export function MatchedVendorsTable({ promises, rfqId, rfqType}: VendorsTablePr // 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 - 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) + + 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) } - - // (c) vendorId state - setSelectedVendorIdForComments(vendorId) - setCommentSheetOpen(true) } - + // 6) 컬럼 정의 (memo) const columns = React.useMemo( () => getColumns({ setRowAction, router, openCommentSheet }), @@ -140,9 +161,16 @@ export function MatchedVendorsTable({ promises, rfqId, rfqType}: VendorsTablePr clearOnDefault: true, }) + // 세션에서 userId 추출하고 숫자로 변환 + const currentUserId = session?.user?.id ? parseInt(session.user.id, 10) : 0 + + console.log(currentUserId,"currentUserId") + return ( - <> - <DataTable table={table}> + <div style={{ maxWidth: '80vw' }}> + <DataTable + table={table} + > <DataTableAdvancedToolbar table={table} filterFields={advancedFilterFields} @@ -169,13 +197,14 @@ export function MatchedVendorsTable({ promises, rfqId, rfqType}: VendorsTablePr initialComments={initialComments} rfqId={rfqId} vendorId={selectedVendorIdForComments ?? 0} - currentUserId={1} + 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 diff --git a/lib/vendor-rfq-response/vendor-rfq-table/comments-sheet.tsx b/lib/vendor-rfq-response/vendor-rfq-table/comments-sheet.tsx index d401f1cd..5bb8a16a 100644 --- a/lib/vendor-rfq-response/vendor-rfq-table/comments-sheet.tsx +++ b/lib/vendor-rfq-response/vendor-rfq-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 { Download, X, Loader2 } from "lucide-react" import prettyBytes from "pretty-bytes" import { toast } from "sonner" @@ -26,150 +26,107 @@ import { FormLabel, FormMessage, } from "@/components/ui/form" -import { - Textarea, -} from "@/components/ui/textarea" - +import { Textarea } from "@/components/ui/textarea" import { Dropzone, DropzoneZone, DropzoneUploadIcon, DropzoneTitle, DropzoneDescription, - DropzoneInput + DropzoneInput, } from "@/components/ui/dropzone" - import { Table, TableHeader, TableRow, TableHead, TableBody, - TableCell + TableCell, } from "@/components/ui/table" -// DB 스키마에서 필요한 타입들을 가져온다고 가정 -import { RfqWithAll } from "../types" - -import { createRfqCommentWithAttachments, updateRfqComment } from "../../rfqs/service" import { formatDate } from "@/lib/utils" +import { createRfqCommentWithAttachments } from "@/lib/rfqs/service" -// 코멘트 + 첨부파일 구조 (단순 예시) -// 실제 DB 스키마에 맞춰 조정 -export interface RfqComment { + +export interface MatchedVendorComment { id: number commentText: string commentedBy?: number - createdAt?: Date + commentedByEmail?: string + createdAt?: Date attachments?: { id: number fileName: string - filePath?: string + filePath: string }[] } +// 1) props 정의 interface CommentSheetProps extends React.ComponentPropsWithRef<typeof Sheet> { - /** 코멘트를 작성할 RFQ 정보 */ - /** 이미 존재하는 모든 코멘트 목록 (서버에서 불러와 주입) */ - initialComments?: RfqComment[] - - /** 사용자(작성자) ID (로그인 세션 등에서 가져옴) */ + initialComments?: MatchedVendorComment[] currentUserId: number - rfq:RfqWithAll - /** 댓글 저장 후 갱신용 콜백 (옵션) */ - onCommentsUpdated?: (comments: RfqComment[]) => void + 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[] + newFiles: z.array(z.any()).optional(), // File[] }) type CommentFormValues = z.infer<typeof commentFormSchema> const MAX_FILE_SIZE = 30e6 // 30MB export function CommentSheet({ - rfq, + rfqId, + vendorId, initialComments = [], currentUserId, onCommentsUpdated, + isLoading = false, // Default to false ...props }: CommentSheetProps) { - const [comments, setComments] = React.useState<RfqComment[]>(initialComments) + + console.log(initialComments) + + const [comments, setComments] = React.useState<MatchedVendorComment[]>(initialComments) const [isPending, startTransition] = React.useTransition() React.useEffect(() => { setComments(initialComments) }, [initialComments]) - - // RHF 세팅 + const form = useForm<CommentFormValues>({ resolver: zodResolver(commentFormSchema), defaultValues: { commentText: "", - newFiles: [] - } + newFiles: [], + }, }) - // formFieldArray 예시 (파일 목록) const { fields: newFileFields, append, remove } = useFieldArray({ control: form.control, - name: "newFiles" + name: "newFiles", }) - // 1) 기존 코멘트 + 첨부 보여주기 - // 간단히 테이블 하나로 표현 - // 실제로는 Bubble 형태의 UI, Accordion, Timeline 등 다양하게 구성할 수 있음 + // (A) 기존 코멘트 렌더링 function renderExistingComments() { - // 1) 편집 상태 관리 - const [editingId, setEditingId] = React.useState<number | null>(null) - const [editText, setEditText] = React.useState("") - - // 2) Edit 시작 핸들러 - function handleEditClick(c: RfqComment) { - setEditingId(c.id) - setEditText(c.commentText) - } - - // 3) Save 핸들러 - async function handleSave(commentId: number) { - try { - // (예시) 서버 액션 or API 요청 - await updateRfqComment({ commentId, commentText: editText }) - - // 만약 단순 로컬 수정만 할 거라면, - // parent state의 comments를 갱신하는 로직 필요 - setComments((prev) => - prev.map((comment) => - comment.id === commentId - ? { ...comment, commentText: editText } - : comment - ) - ) - - toast.success("Comment updated.") - } catch (err) { - toast.error("Error updating comment.") - } finally { - // 편집 모드 종료 - setEditingId(null) - setEditText("") - } - } - - // 4) Cancel 핸들러 - function handleCancel() { - setEditingId(null) - setEditText("") + + 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> + ) } - - // 만약 comments가 비어 있다면 + if (comments.length === 0) { return <p className="text-sm text-muted-foreground">No comments yet</p> } - - // 5) 테이블 렌더링 return ( <Table> <TableHeader> @@ -178,42 +135,22 @@ export function CommentSheet({ <TableHead>Attachments</TableHead> <TableHead>Created At</TableHead> <TableHead>Created By</TableHead> - - {/* 추가된 Actions 컬럼 */} - <TableHead>Actions</TableHead> </TableRow> </TableHeader> <TableBody> - {comments.map((c) => ( + {comments.map((c) => ( <TableRow key={c.id}> - {/* 1) Comment 셀 */} + <TableCell>{c.commentText}</TableCell> <TableCell> - {/* 현재 행이 editing 모드인지 체크 */} - {editingId === c.id ? ( - // 편집 모드 - <textarea - value={editText} - onChange={(e) => setEditText(e.target.value)} - className="w-full border p-1 rounded" - rows={3} - /> - ) : ( - // 일반 모드 - c.commentText - )} - </TableCell> - - {/* 2) Attachments 셀 (기존과 동일) */} - <TableCell> - {(!c.attachments || c.attachments.length === 0) && ( + {!c.attachments?.length && ( <span className="text-sm text-muted-foreground">No files</span> )} - {c.attachments && c.attachments.length > 0 && ( + {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={att.filePath} + href={`/api/rfq-download?path=${encodeURIComponent(att.filePath)}`} download target="_blank" rel="noreferrer" @@ -227,32 +164,8 @@ export function CommentSheet({ </div> )} </TableCell> - - {/* 3) Created At */} <TableCell> { c.createdAt ? formatDate(c.createdAt): "-"}</TableCell> - - {/* 4) Created By */} - <TableCell>{c.commentedBy ?? "-"}</TableCell> - - {/* 5) 새로 추가된 Actions */} - <TableCell> - {editingId === c.id ? ( - // 편집 중일 때 - <div className="flex gap-2"> - <Button variant="outline" size="sm" onClick={() => handleSave(c.id)}> - Save - </Button> - <Button variant="ghost" size="sm" onClick={handleCancel}> - Cancel - </Button> - </div> - ) : ( - // 일반 상태 - <Button variant="outline" size="sm" onClick={() => handleEditClick(c)}> - Edit - </Button> - )} - </TableCell> + <TableCell>{c.commentedByEmail ?? "-"}</TableCell> </TableRow> ))} </TableBody> @@ -260,28 +173,24 @@ export function CommentSheet({ ) } - // 2) 새 파일 Drop + // (B) 파일 드롭 function handleDropAccepted(files: File[]) { - // 드롭된 File[]을 RHF field array에 추가 - const toAppend = files.map((f) => f) - append(toAppend) + append(files) } - - // 3) 저장(Submit) + // (C) Submit async function onSubmit(data: CommentFormValues) { - - if (!rfq) return + if (!rfqId) return startTransition(async () => { try { - // 서버 액션 호출 const res = await createRfqCommentWithAttachments({ - rfqId: rfq.id, - vendorId: rfq.vendorId, // 필요시 세팅 + rfqId, + vendorId, commentText: data.commentText, commentedBy: currentUserId, - evaluationId: null, // 필요시 세팅 - files: data.newFiles + evaluationId: null, + cbeId: null, + files: data.newFiles, }) if (!res.ok) { @@ -290,22 +199,22 @@ export function CommentSheet({ toast.success("Comment created") - // 새 코멘트를 다시 불러오거나, - // 여기서는 임시로 "새로운 코멘트가 추가됐다" 라고 가정하여 클라이언트에서 상태 업데이트 - const newComment: RfqComment = { - id: res.commentId, // 서버에서 반환된 commentId + // 임시로 새 코멘트 추가 + const newComment: MatchedVendorComment = { + id: res.commentId, // 서버 응답 commentText: data.commentText, commentedBy: currentUserId, createdAt: res.createdAt, - attachments: (data.newFiles?.map((f, idx) => ({ - id: Math.random() * 100000, - fileName: f.name, - })) || []) + 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) @@ -324,12 +233,8 @@ export function CommentSheet({ </SheetDescription> </SheetHeader> - {/* 기존 코멘트 목록 */} - <div className="max-h-[300px] overflow-y-auto"> - {renderExistingComments()} - </div> + <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 @@ -339,17 +244,13 @@ export function CommentSheet({ <FormItem> <FormLabel>New Comment</FormLabel> <FormControl> - <Textarea - placeholder="Enter your comment..." - {...field} - /> + <Textarea placeholder="Enter your comment..." {...field} /> </FormControl> <FormMessage /> </FormItem> )} /> - {/* Dropzone (파일 첨부) */} <Dropzone maxSize={MAX_FILE_SIZE} onDropAccepted={handleDropAccepted} @@ -373,15 +274,19 @@ export function CommentSheet({ )} </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> + <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" @@ -403,7 +308,7 @@ export function CommentSheet({ </Button> </SheetClose> <Button disabled={isPending}> - {isPending && <Loader className="mr-2 h-4 w-4 animate-spin" />} + {isPending && <Loader2 className="mr-2 h-4 w-4 animate-spin" />} Save </Button> </SheetFooter> diff --git a/lib/vendor-rfq-response/vendor-rfq-table/rfqs-table.tsx b/lib/vendor-rfq-response/vendor-rfq-table/rfqs-table.tsx index 337c2875..6aab7fef 100644 --- a/lib/vendor-rfq-response/vendor-rfq-table/rfqs-table.tsx +++ b/lib/vendor-rfq-response/vendor-rfq-table/rfqs-table.tsx @@ -24,22 +24,25 @@ import { import { RfqsVendorTableToolbarActions } from "./rfqs-table-toolbar-actions" import { RfqsItemsDialog } from "./ItemsDialog" import { RfqAttachmentsSheet } from "./attachment-rfq-sheet" -import { CommentSheet, RfqComment } from "./comments-sheet" +import { CommentSheet } from "./comments-sheet" import { getRfqResponsesForVendor } from "../service" +import { useSession } from "next-auth/react" // Next-auth session hook 추가 interface RfqsTableProps { promises: Promise<[Awaited<ReturnType<typeof getRfqResponsesForVendor>>]> } // 코멘트+첨부파일 구조 예시 -export interface RfqCommentWithAttachments extends RfqComment { +export interface RfqCommentWithAttachments { + id: number + commentText: string + commentedBy?: number + commentedByEmail?: string + createdAt?: Date attachments?: { id: number fileName: string filePath: string - createdAt?: Date - vendorId?: number | null - size?: number }[] } @@ -62,6 +65,7 @@ export interface ExistingItem { export function RfqsVendorTable({ promises }: RfqsTableProps) { const { featureFlags } = useFeatureFlags() + const { data: session } = useSession() // 세션 정보 가져오기 // 1) 테이블 데이터( RFQs ) const [{ data: responseData, pageCount }] = React.use(promises) @@ -226,6 +230,11 @@ export function RfqsVendorTable({ promises }: RfqsTableProps) { clearOnDefault: true, }) + const currentUserId = session?.user?.id ? parseInt(session.user.id, 10) : 0 + const currentVendorId = session?.user?.id ? session.user.companyId : 0 + + + return ( <> <DataTable table={table}> @@ -250,11 +259,12 @@ export function RfqsVendorTable({ promises }: RfqsTableProps) { {/* 2) 코멘트 시트 */} {selectedRfqIdForComments && ( <CommentSheet - currentUserId={1} open={commentSheetOpen} onOpenChange={setCommentSheetOpen} initialComments={initialComments} - rfq={data.find(item => item.rfqId === selectedRfqIdForComments)!} + rfqId={selectedRfqIdForComments} + vendorId={currentVendorId??0} + currentUserId={currentUserId} /> )} 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 34a53d17..7a95d7ed 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 @@ -269,7 +269,7 @@ export function getColumns({ const tbeId = vendor.tbeId const vendorId = vendor.vendorId const rfqId = vendor.rfqId - const vendorResponseId = vendor.vendorResponseId + const vendorResponseId = vendor.vendorResponseId || 0 const status = vendor.rfqVendorStatus const hasResponse = vendor.hasResponse || false diff --git a/lib/vendor-rfq-response/vendor-tbe-table/tbeFileHandler.tsx b/lib/vendor-rfq-response/vendor-tbe-table/tbeFileHandler.tsx index 3994b8eb..4efaee77 100644 --- a/lib/vendor-rfq-response/vendor-tbe-table/tbeFileHandler.tsx +++ b/lib/vendor-rfq-response/vendor-tbe-table/tbeFileHandler.tsx @@ -143,7 +143,7 @@ export function useTbeFileHandlers() { const downloadSubmittedFile = useCallback((file: { id: number; fileName: string; filePath: string }) => { try { const link = document.createElement("a"); - link.href = `/api/files/${file.filePath}`; + link.href = `/api/tbe-download?path=${encodeURIComponent(file.filePath)}`; link.download = file.fileName; document.body.appendChild(link); link.click(); diff --git a/lib/vendors/table/request-vendor-pg-dialog.tsx b/lib/vendors/table/request-vendor-pg-dialog.tsx index b417f846..de23ad9b 100644 --- a/lib/vendors/table/request-vendor-pg-dialog.tsx +++ b/lib/vendors/table/request-vendor-pg-dialog.tsx @@ -70,7 +70,7 @@ export function RequestPQVendorsDialog({ <DialogTrigger asChild> <Button variant="outline" size="sm" className="gap-2"> <SendHorizonal className="size-4" aria-hidden="true" /> - Request ({vendors.length}) + PQ Request ({vendors.length}) </Button> </DialogTrigger> ) : null} @@ -114,7 +114,7 @@ export function RequestPQVendorsDialog({ <DrawerTrigger asChild> <Button variant="outline" size="sm" className="gap-2"> <Check className="size-4" aria-hidden="true" /> - Request ({vendors.length}) + PQ Request ({vendors.length}) </Button> </DrawerTrigger> ) : null} |
