summaryrefslogtreecommitdiff
path: root/lib/tech-vendor-rfq-response/vendor-rfq-table
diff options
context:
space:
mode:
Diffstat (limited to 'lib/tech-vendor-rfq-response/vendor-rfq-table')
-rw-r--r--lib/tech-vendor-rfq-response/vendor-rfq-table/ItemsDialog.tsx127
-rw-r--r--lib/tech-vendor-rfq-response/vendor-rfq-table/attachment-rfq-sheet.tsx106
-rw-r--r--lib/tech-vendor-rfq-response/vendor-rfq-table/comments-sheet.tsx320
-rw-r--r--lib/tech-vendor-rfq-response/vendor-rfq-table/feature-flags-provider.tsx108
-rw-r--r--lib/tech-vendor-rfq-response/vendor-rfq-table/rfqs-table-columns.tsx424
-rw-r--r--lib/tech-vendor-rfq-response/vendor-rfq-table/rfqs-table-toolbar-actions.tsx40
-rw-r--r--lib/tech-vendor-rfq-response/vendor-rfq-table/rfqs-table.tsx280
7 files changed, 1405 insertions, 0 deletions
diff --git a/lib/tech-vendor-rfq-response/vendor-rfq-table/ItemsDialog.tsx b/lib/tech-vendor-rfq-response/vendor-rfq-table/ItemsDialog.tsx
new file mode 100644
index 00000000..da656356
--- /dev/null
+++ b/lib/tech-vendor-rfq-response/vendor-rfq-table/ItemsDialog.tsx
@@ -0,0 +1,127 @@
+"use client"
+
+import * as React from "react"
+import {
+ Dialog,
+ DialogContent,
+ DialogHeader,
+ DialogTitle,
+ DialogDescription,
+ DialogFooter,
+} from "@/components/ui/dialog"
+import { Button } from "@/components/ui/button"
+import {
+ Table,
+ TableBody,
+ TableCell,
+ TableHead,
+ TableHeader,
+ TableRow,
+} from "@/components/ui/table"
+import { RfqWithAll } from "../types"
+/**
+ * 아이템 구조 예시
+ * - API 응답에서 quantity가 "string" 형태이므로,
+ * 숫자로 사용하실 거라면 parse 과정이 필요할 수 있습니다.
+ */
+export interface RfqItem {
+ id: number
+ itemCode: string
+ itemName: string
+ itemList: string | null
+ subItemList: string | null
+ quantity: string
+ description: string
+ uom: string
+}
+
+/**
+ * 첨부파일 구조 예시
+ */
+export interface RfqAttachment {
+ id: number
+ fileName: string
+ filePath: string
+ vendorId: number | null
+ evaluationId: number | null
+}
+
+
+/**
+ * 다이얼로그 내에서만 사용할 단순 아이템 구조 (예: 임시/기본값 표출용)
+ */
+export interface DefaultItem {
+ id?: number
+ itemCode: string
+ description?: string | null
+ quantity?: number | null
+ uom?: string | null
+}
+
+/**
+ * RfqsItemsDialog 컴포넌트 Prop 타입
+ */
+export interface RfqsItemsDialogProps {
+ open: boolean
+ onOpenChange: (open: boolean) => void
+ rfq: RfqWithAll
+ defaultItems?: DefaultItem[]
+}
+
+export function RfqsItemsDialog({
+ open,
+ onOpenChange,
+ rfq,
+}: RfqsItemsDialogProps) {
+ return (
+ <Dialog open={open} onOpenChange={onOpenChange}>
+ <DialogContent className="max-w-none w-[1200px]">
+ <DialogHeader>
+ <DialogTitle>Items for RFQ {rfq?.rfqCode}</DialogTitle>
+ <DialogDescription>
+ Below is the list of items for this RFQ.
+ </DialogDescription>
+ </DialogHeader>
+
+ <div className="overflow-x-auto w-full space-y-4">
+ {rfq && rfq.items.length === 0 && (
+ <p className="text-sm text-muted-foreground">No items found.</p>
+ )}
+ {rfq && rfq.items.length > 0 && (
+ <Table>
+ {/* 필요에 따라 TableCaption 등을 추가해도 좋습니다. */}
+ <TableHeader>
+ <TableRow>
+ <TableHead>Item Code</TableHead>
+ <TableHead>Item List</TableHead>
+ <TableHead>Sub Item List</TableHead>
+ <TableHead>Description</TableHead>
+ <TableHead>Qty</TableHead>
+ <TableHead>UoM</TableHead>
+ </TableRow>
+ </TableHeader>
+ <TableBody>
+ {rfq.items.map((it, idx) => (
+ <TableRow key={it.id ?? idx}>
+ <TableCell>{it.itemCode || "No Code"}</TableCell>
+ <TableCell>{it.itemList || "-"}</TableCell>
+ <TableCell>{it.subItemList || "-"}</TableCell>
+ <TableCell>{it.description || "-"}</TableCell>
+ <TableCell>{it.quantity ?? 1}</TableCell>
+ <TableCell>{it.uom ?? "each"}</TableCell>
+ </TableRow>
+ ))}
+ </TableBody>
+ </Table>
+ )}
+ </div>
+
+ <DialogFooter className="mt-4">
+ <Button type="button" variant="outline" onClick={() => onOpenChange(false)}>
+ Close
+ </Button>
+ </DialogFooter>
+ </DialogContent>
+ </Dialog>
+ )
+} \ No newline at end of file
diff --git a/lib/tech-vendor-rfq-response/vendor-rfq-table/attachment-rfq-sheet.tsx b/lib/tech-vendor-rfq-response/vendor-rfq-table/attachment-rfq-sheet.tsx
new file mode 100644
index 00000000..6c51c12c
--- /dev/null
+++ b/lib/tech-vendor-rfq-response/vendor-rfq-table/attachment-rfq-sheet.tsx
@@ -0,0 +1,106 @@
+"use client"
+
+import * as React from "react"
+import {
+ Sheet,
+ SheetContent,
+ SheetHeader,
+ SheetTitle,
+ SheetDescription,
+ SheetFooter,
+ SheetClose,
+} from "@/components/ui/sheet"
+import { Button } from "@/components/ui/button"
+import { Download } from "lucide-react"
+import { formatDate } from "@/lib/utils"
+
+// 첨부파일 구조
+interface RfqAttachment {
+ id: number
+ fileName: string
+ filePath: string
+ createdAt?: Date // or Date
+ vendorId?: number | null
+ size?: number
+}
+
+// 컴포넌트 Prop
+interface RfqAttachmentsSheetProps extends React.ComponentPropsWithRef<typeof Sheet> {
+ rfqId: number
+ attachments?: RfqAttachment[]
+}
+
+/**
+ * RfqAttachmentsSheet:
+ * - 단순히 첨부파일 리스트 + 다운로드 버튼만
+ */
+export function RfqAttachmentsSheet({
+ rfqId,
+ attachments = [],
+ ...props
+}: RfqAttachmentsSheetProps) {
+ return (
+ <Sheet {...props}>
+ <SheetContent className="flex flex-col gap-6 sm:max-w-sm">
+ <SheetHeader>
+ <SheetTitle>Attachments</SheetTitle>
+ <SheetDescription>RFQ #{rfqId}에 대한 첨부파일 목록</SheetDescription>
+ </SheetHeader>
+
+ <div className="space-y-2">
+ {/* 첨부파일이 없을 경우 */}
+ {attachments.length === 0 && (
+ <p className="text-sm text-muted-foreground">
+ No attachments
+ </p>
+ )}
+
+ {/* 첨부파일 목록 */}
+ {attachments.map((att) => (
+ <div
+ key={att.id}
+ className="flex items-center justify-between rounded border p-2"
+ >
+ <div className="flex flex-col text-sm">
+ <span className="font-medium">{att.fileName}</span>
+ {att.size && (
+ <span className="text-xs text-muted-foreground">
+ {Math.round(att.size / 1024)} KB
+ </span>
+ )}
+ {att.createdAt && (
+ <span className="text-xs text-muted-foreground">
+ Created at {formatDate(att.createdAt)}
+ </span>
+ )}
+ </div>
+ {/* 파일 다운로드 버튼 */}
+ {att.filePath && (
+ <a
+ href={att.filePath}
+ download
+ target="_blank"
+ rel="noreferrer"
+ className="text-sm"
+ >
+ <Button variant="ghost" size="icon" type="button">
+ <Download className="h-4 w-4" />
+ </Button>
+ </a>
+ )}
+ </div>
+ ))}
+ </div>
+
+ <SheetFooter className="gap-2 pt-2">
+ {/* 닫기 버튼 */}
+ <SheetClose asChild>
+ <Button type="button" variant="outline">
+ Close
+ </Button>
+ </SheetClose>
+ </SheetFooter>
+ </SheetContent>
+ </Sheet>
+ )
+} \ No newline at end of file
diff --git a/lib/tech-vendor-rfq-response/vendor-rfq-table/comments-sheet.tsx b/lib/tech-vendor-rfq-response/vendor-rfq-table/comments-sheet.tsx
new file mode 100644
index 00000000..8904fcff
--- /dev/null
+++ b/lib/tech-vendor-rfq-response/vendor-rfq-table/comments-sheet.tsx
@@ -0,0 +1,320 @@
+"use client"
+
+import * as React from "react"
+import { useForm, useFieldArray } from "react-hook-form"
+import { z } from "zod"
+import { zodResolver } from "@hookform/resolvers/zod"
+import { Download, X, Loader2 } from "lucide-react"
+import prettyBytes from "pretty-bytes"
+import { toast } from "sonner"
+
+import {
+ Sheet,
+ SheetClose,
+ SheetContent,
+ SheetDescription,
+ SheetFooter,
+ SheetHeader,
+ SheetTitle,
+} from "@/components/ui/sheet"
+import { Button } from "@/components/ui/button"
+import {
+ Form,
+ FormControl,
+ FormField,
+ FormItem,
+ FormLabel,
+ FormMessage,
+} from "@/components/ui/form"
+import { Textarea } from "@/components/ui/textarea"
+import {
+ Dropzone,
+ DropzoneZone,
+ DropzoneUploadIcon,
+ DropzoneTitle,
+ DropzoneDescription,
+ DropzoneInput,
+} from "@/components/ui/dropzone"
+import {
+ Table,
+ TableHeader,
+ TableRow,
+ TableHead,
+ TableBody,
+ TableCell,
+} from "@/components/ui/table"
+
+import { formatDate } from "@/lib/utils"
+import { createRfqCommentWithAttachments } from "@/lib/rfqs-tech/service"
+
+
+export interface MatchedVendorComment {
+ id: number
+ commentText: string
+ commentedBy?: number
+ commentedByEmail?: string
+ createdAt?: Date
+ attachments?: {
+ id: number
+ fileName: string
+ filePath: string
+ }[]
+}
+
+// 1) props 정의
+interface CommentSheetProps extends React.ComponentPropsWithRef<typeof Sheet> {
+ initialComments?: MatchedVendorComment[]
+ currentUserId: number
+ rfqId: number
+ vendorId: number
+ onCommentsUpdated?: (comments: MatchedVendorComment[]) => void
+ isLoading?: boolean // New prop
+}
+
+// 2) 폼 스키마
+const commentFormSchema = z.object({
+ commentText: z.string().min(1, "댓글을 입력하세요."),
+ newFiles: z.array(z.any()).optional(), // File[]
+})
+type CommentFormValues = z.infer<typeof commentFormSchema>
+
+const MAX_FILE_SIZE = 30e6 // 30MB
+
+export function CommentSheet({
+ rfqId,
+ vendorId,
+ initialComments = [],
+ currentUserId,
+ onCommentsUpdated,
+ isLoading = false, // Default to false
+ ...props
+}: CommentSheetProps) {
+
+ console.log(initialComments)
+
+ const [comments, setComments] = React.useState<MatchedVendorComment[]>(initialComments)
+ const [isPending, startTransition] = React.useTransition()
+
+ React.useEffect(() => {
+ setComments(initialComments)
+ }, [initialComments])
+
+ const form = useForm<CommentFormValues>({
+ resolver: zodResolver(commentFormSchema),
+ defaultValues: {
+ commentText: "",
+ newFiles: [],
+ },
+ })
+
+ const { fields: newFileFields, append, remove } = useFieldArray({
+ control: form.control,
+ name: "newFiles",
+ })
+
+ // (A) 기존 코멘트 렌더링
+ function renderExistingComments() {
+
+ if (isLoading) {
+ return (
+ <div className="flex justify-center items-center h-32">
+ <Loader2 className="h-4 w-4 animate-spin text-muted-foreground" />
+ <span className="ml-2 text-sm text-muted-foreground">Loading comments...</span>
+ </div>
+ )
+ }
+
+ if (comments.length === 0) {
+ return <p className="text-sm text-muted-foreground">No comments yet</p>
+ }
+ return (
+ <Table>
+ <TableHeader>
+ <TableRow>
+ <TableHead className="w-1/2">Comment</TableHead>
+ <TableHead>Attachments</TableHead>
+ <TableHead>Created At</TableHead>
+ <TableHead>Created By</TableHead>
+ </TableRow>
+ </TableHeader>
+ <TableBody>
+ {comments.map((c) => (
+ <TableRow key={c.id}>
+ <TableCell>{c.commentText}</TableCell>
+ <TableCell>
+ {!c.attachments?.length && (
+ <span className="text-sm text-muted-foreground">No files</span>
+ )}
+ {c.attachments?.length && (
+ <div className="flex flex-col gap-1">
+ {c.attachments.map((att) => (
+ <div key={att.id} className="flex items-center gap-2">
+ <a
+ href={`/api/rfq-download?path=${encodeURIComponent(att.filePath)}`}
+ download
+ target="_blank"
+ rel="noreferrer"
+ className="inline-flex items-center gap-1 text-blue-600 underline"
+ >
+ <Download className="h-4 w-4" />
+ {att.fileName}
+ </a>
+ </div>
+ ))}
+ </div>
+ )}
+ </TableCell>
+ <TableCell> { c.createdAt ? formatDate(c.createdAt): "-"}</TableCell>
+ <TableCell>{c.commentedByEmail ?? "-"}</TableCell>
+ </TableRow>
+ ))}
+ </TableBody>
+ </Table>
+ )
+ }
+
+ // (B) 파일 드롭
+ function handleDropAccepted(files: File[]) {
+ append(files)
+ }
+
+ // (C) Submit
+ async function onSubmit(data: CommentFormValues) {
+ if (!rfqId) return
+ startTransition(async () => {
+ try {
+ const res = await createRfqCommentWithAttachments({
+ rfqId,
+ vendorId,
+ commentText: data.commentText,
+ commentedBy: currentUserId,
+ evaluationId: null,
+ cbeId: null,
+ files: data.newFiles,
+ })
+
+ if (!res.ok) {
+ throw new Error("Failed to create comment")
+ }
+
+ toast.success("Comment created")
+
+ // 임시로 새 코멘트 추가
+ const newComment: MatchedVendorComment = {
+ id: res.commentId, // 서버 응답
+ commentText: data.commentText,
+ commentedBy: currentUserId,
+ createdAt: res.createdAt,
+ attachments:
+ data.newFiles?.map((f) => ({
+ id: Math.floor(Math.random() * 1e6),
+ fileName: f.name,
+ filePath: "/uploads/" + f.name,
+ })) || [],
+ }
+ setComments((prev) => [...prev, newComment])
+ onCommentsUpdated?.([...comments, newComment])
+
+ form.reset()
+ } catch (err: any) {
+ console.error(err)
+ toast.error("Error: " + err.message)
+ }
+ })
+ }
+
+ return (
+ <Sheet {...props}>
+ <SheetContent className="flex flex-col gap-6 sm:max-w-lg">
+ <SheetHeader className="text-left">
+ <SheetTitle>Comments</SheetTitle>
+ <SheetDescription>
+ 필요시 첨부파일과 함께 문의/코멘트를 남길 수 있습니다.
+ </SheetDescription>
+ </SheetHeader>
+
+ <div className="max-h-[300px] overflow-y-auto">{renderExistingComments()}</div>
+
+ <Form {...form}>
+ <form onSubmit={form.handleSubmit(onSubmit)} className="flex flex-col gap-4">
+ <FormField
+ control={form.control}
+ name="commentText"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>New Comment</FormLabel>
+ <FormControl>
+ <Textarea placeholder="Enter your comment..." {...field} />
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+
+ <Dropzone
+ maxSize={MAX_FILE_SIZE}
+ onDropAccepted={handleDropAccepted}
+ onDropRejected={(rej) => {
+ toast.error("File rejected: " + (rej[0]?.file?.name || ""))
+ }}
+ >
+ {({ maxSize }) => (
+ <DropzoneZone className="flex justify-center">
+ <DropzoneInput />
+ <div className="flex items-center gap-6">
+ <DropzoneUploadIcon />
+ <div className="grid gap-0.5">
+ <DropzoneTitle>Drop to attach files</DropzoneTitle>
+ <DropzoneDescription>
+ Max size: {prettyBytes(maxSize || 0)}
+ </DropzoneDescription>
+ </div>
+ </div>
+ </DropzoneZone>
+ )}
+ </Dropzone>
+
+ {newFileFields.length > 0 && (
+ <div className="flex flex-col gap-2">
+ {newFileFields.map((field, idx) => {
+ const file = form.getValues(`newFiles.${idx}`)
+ if (!file) return null
+ return (
+ <div
+ key={field.id}
+ className="flex items-center justify-between border rounded p-2"
+ >
+ <span className="text-sm">
+ {file.name} ({prettyBytes(file.size)})
+ </span>
+ <Button
+ variant="ghost"
+ size="icon"
+ type="button"
+ onClick={() => remove(idx)}
+ >
+ <X className="h-4 w-4" />
+ </Button>
+ </div>
+ )
+ })}
+ </div>
+ )}
+
+ <SheetFooter className="gap-2 pt-4">
+ <SheetClose asChild>
+ <Button type="button" variant="outline">
+ Cancel
+ </Button>
+ </SheetClose>
+ <Button disabled={isPending}>
+ {isPending && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
+ Save
+ </Button>
+ </SheetFooter>
+ </form>
+ </Form>
+ </SheetContent>
+ </Sheet>
+ )
+} \ No newline at end of file
diff --git a/lib/tech-vendor-rfq-response/vendor-rfq-table/feature-flags-provider.tsx b/lib/tech-vendor-rfq-response/vendor-rfq-table/feature-flags-provider.tsx
new file mode 100644
index 00000000..81131894
--- /dev/null
+++ b/lib/tech-vendor-rfq-response/vendor-rfq-table/feature-flags-provider.tsx
@@ -0,0 +1,108 @@
+"use client"
+
+import * as React from "react"
+import { useQueryState } from "nuqs"
+
+import { dataTableConfig, type DataTableConfig } from "@/config/data-table"
+import { cn } from "@/lib/utils"
+import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group"
+import {
+ Tooltip,
+ TooltipContent,
+ TooltipTrigger,
+} from "@/components/ui/tooltip"
+
+type FeatureFlagValue = DataTableConfig["featureFlags"][number]["value"]
+
+interface FeatureFlagsContextProps {
+ featureFlags: FeatureFlagValue[]
+ setFeatureFlags: (value: FeatureFlagValue[]) => void
+}
+
+const FeatureFlagsContext = React.createContext<FeatureFlagsContextProps>({
+ featureFlags: [],
+ setFeatureFlags: () => {},
+})
+
+export function useFeatureFlags() {
+ const context = React.useContext(FeatureFlagsContext)
+ if (!context) {
+ throw new Error(
+ "useFeatureFlags must be used within a FeatureFlagsProvider"
+ )
+ }
+ return context
+}
+
+interface FeatureFlagsProviderProps {
+ children: React.ReactNode
+}
+
+export function FeatureFlagsProvider({ children }: FeatureFlagsProviderProps) {
+ const [featureFlags, setFeatureFlags] = useQueryState<FeatureFlagValue[]>(
+ "flags",
+ {
+ defaultValue: [],
+ parse: (value) => value.split(",") as FeatureFlagValue[],
+ serialize: (value) => value.join(","),
+ eq: (a, b) =>
+ a.length === b.length && a.every((value, index) => value === b[index]),
+ clearOnDefault: true,
+ shallow: false,
+ }
+ )
+
+ return (
+ <FeatureFlagsContext.Provider
+ value={{
+ featureFlags,
+ setFeatureFlags: (value) => void setFeatureFlags(value),
+ }}
+ >
+ <div className="w-full overflow-x-auto">
+ <ToggleGroup
+ type="multiple"
+ variant="outline"
+ size="sm"
+ value={featureFlags}
+ onValueChange={(value: FeatureFlagValue[]) => setFeatureFlags(value)}
+ className="w-fit gap-0"
+ >
+ {dataTableConfig.featureFlags.map((flag, index) => (
+ <Tooltip key={flag.value}>
+ <ToggleGroupItem
+ value={flag.value}
+ className={cn(
+ "gap-2 whitespace-nowrap rounded-none px-3 text-xs data-[state=on]:bg-accent/70 data-[state=on]:hover:bg-accent/90",
+ {
+ "rounded-l-sm border-r-0": index === 0,
+ "rounded-r-sm":
+ index === dataTableConfig.featureFlags.length - 1,
+ }
+ )}
+ asChild
+ >
+ <TooltipTrigger>
+ <flag.icon className="size-3.5 shrink-0" aria-hidden="true" />
+ {flag.label}
+ </TooltipTrigger>
+ </ToggleGroupItem>
+ <TooltipContent
+ align="start"
+ side="bottom"
+ sideOffset={6}
+ className="flex max-w-60 flex-col space-y-1.5 border bg-background py-2 font-semibold text-foreground"
+ >
+ <div>{flag.tooltipTitle}</div>
+ <div className="text-xs text-muted-foreground">
+ {flag.tooltipDescription}
+ </div>
+ </TooltipContent>
+ </Tooltip>
+ ))}
+ </ToggleGroup>
+ </div>
+ {children}
+ </FeatureFlagsContext.Provider>
+ )
+}
diff --git a/lib/tech-vendor-rfq-response/vendor-rfq-table/rfqs-table-columns.tsx b/lib/tech-vendor-rfq-response/vendor-rfq-table/rfqs-table-columns.tsx
new file mode 100644
index 00000000..69a5e7e7
--- /dev/null
+++ b/lib/tech-vendor-rfq-response/vendor-rfq-table/rfqs-table-columns.tsx
@@ -0,0 +1,424 @@
+"use client"
+
+import * as React from "react"
+import { useRouter } from "next/navigation"
+import { ColumnDef } from "@tanstack/react-table"
+import {
+ Ellipsis,
+ MessageSquare,
+ Package,
+ Paperclip,
+} from "lucide-react"
+import { toast } from "sonner"
+
+import { Button } from "@/components/ui/button"
+import { Checkbox } from "@/components/ui/checkbox"
+import {
+ DropdownMenu,
+ DropdownMenuContent,
+ DropdownMenuItem,
+ DropdownMenuRadioGroup,
+ DropdownMenuRadioItem,
+ DropdownMenuSub,
+ DropdownMenuSubContent,
+ DropdownMenuSubTrigger,
+ DropdownMenuTrigger
+} from "@/components/ui/dropdown-menu"
+import { DataTableColumnHeaderSimple } from "@/components/data-table/data-table-column-simple-header"
+import { Badge } from "@/components/ui/badge"
+
+import { getErrorMessage } from "@/lib/handle-error"
+import { formatDate, formatDateTime } from "@/lib/utils"
+import { modifyRfqVendor } from "../../rfqs-tech/service"
+import type { RfqWithAll } from "../types"
+import type { DataTableRowAction } from "@/types/table"
+
+type NextRouter = ReturnType<typeof useRouter>
+
+interface GetColumnsProps {
+ setRowAction: React.Dispatch<
+ React.SetStateAction<DataTableRowAction<RfqWithAll> | null>
+ >
+ router: NextRouter
+ openAttachmentsSheet: (rfqId: number) => void
+ openCommentSheet: (rfqId: number) => void
+}
+
+/**
+ * tanstack table 컬럼 정의 (Nested Header)
+ */
+export function getColumns({
+ setRowAction,
+ router,
+ openAttachmentsSheet,
+ openCommentSheet,
+}: GetColumnsProps): ColumnDef<RfqWithAll>[] {
+ // 1) 체크박스(Select) 컬럼
+ const selectColumn: ColumnDef<RfqWithAll> = {
+ id: "select",
+ header: ({ table }) => (
+ <Checkbox
+ checked={
+ table.getIsAllPageRowsSelected() ||
+ (table.getIsSomePageRowsSelected() && "indeterminate")
+ }
+ onCheckedChange={(value) => table.toggleAllPageRowsSelected(!!value)}
+ aria-label="Select all"
+ />
+ ),
+ cell: ({ row }) => (
+ <Checkbox
+ checked={row.getIsSelected()}
+ onCheckedChange={(value) => row.toggleSelected(!!value)}
+ aria-label="Select row"
+ />
+ ),
+ size: 40,
+ enableSorting: false,
+ enableHiding: false,
+ }
+
+ // 2) Actions (Dropdown)
+ const actionsColumn: ColumnDef<RfqWithAll> = {
+ id: "actions",
+ enableHiding: false,
+ cell: ({ row }) => {
+ const [isUpdatePending, startUpdateTransition] = React.useTransition()
+
+ return (
+ <DropdownMenu>
+ <DropdownMenuTrigger asChild>
+ <Button variant="ghost" size="icon">
+ <Ellipsis className="h-4 w-4" aria-hidden="true" />
+ </Button>
+ </DropdownMenuTrigger>
+ <DropdownMenuContent align="end" className="w-56">
+ <DropdownMenuSub>
+ <DropdownMenuSubTrigger>RFQ Response</DropdownMenuSubTrigger>
+ <DropdownMenuSubContent>
+ <DropdownMenuRadioGroup
+ value={row.original.responseStatus}
+ onValueChange={(value) => {
+ startUpdateTransition(async () => {
+ let newStatus:
+ | "ACCEPTED"
+ | "DECLINED"
+ | "REVIEWING"
+
+ switch (value) {
+ case "ACCEPTED":
+ newStatus = "ACCEPTED"
+ break
+ case "DECLINED":
+ newStatus = "DECLINED"
+ break
+ default:
+ newStatus = "REVIEWING"
+ }
+
+ await toast.promise(
+ modifyRfqVendor({
+ id: row.original.responseId,
+ status: newStatus,
+ }),
+ {
+ loading: "Updating response status...",
+ success: "Response status updated",
+ error: (err) => getErrorMessage(err),
+ }
+ )
+ })
+ }}
+ >
+ {[
+ { value: "ACCEPTED", label: "Accept RFQ" },
+ { value: "DECLINED", label: "Decline RFQ" },
+ ].map((rep) => (
+ <DropdownMenuRadioItem
+ key={rep.value}
+ value={rep.value}
+ className="capitalize"
+ disabled={isUpdatePending}
+ >
+ {rep.label}
+ </DropdownMenuRadioItem>
+ ))}
+ </DropdownMenuRadioGroup>
+ </DropdownMenuSubContent>
+ </DropdownMenuSub>
+ {/* <DropdownMenuItem
+ onClick={() => {
+ router.push(`/vendor/rfqs/${row.original.rfqId}`)
+ }}
+ >
+ View Details
+ </DropdownMenuItem> */}
+ {/* <DropdownMenuItem onClick={() => openAttachmentsSheet(row.original.rfqId)}>
+ View Attachments
+ </DropdownMenuItem>
+ <DropdownMenuItem onClick={() => openCommentSheet(row.original.rfqId)}>
+ View Comments
+ </DropdownMenuItem>
+ <DropdownMenuItem onClick={() => setRowAction({ row, type: "items" })}>
+ View Items
+ </DropdownMenuItem> */}
+ </DropdownMenuContent>
+ </DropdownMenu>
+ )
+ },
+ size: 40,
+ }
+
+ // 3) RFQ Code 컬럼
+ const rfqCodeColumn: ColumnDef<RfqWithAll> = {
+ id: "rfqCode",
+ accessorKey: "rfqCode",
+ enableResizing: true,
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="RFQ Code" />
+ ),
+ // cell: ({ row }) => {
+ // return (
+ // <Button
+ // variant="link"
+ // className="p-0 h-auto font-medium"
+ // onClick={() => router.push(`/vendor/rfqs/${row.original.rfqId}`)}
+ // >
+ // {row.original.rfqCode}
+ // </Button>
+ // )
+ // },
+ cell: ({ row }) => row.original.rfqCode || "-",
+ size: 150,
+ }
+
+
+
+ // 4) 응답 상태 컬럼
+ const responseStatusColumn: ColumnDef<RfqWithAll> = {
+ id: "responseStatus",
+ accessorKey: "responseStatus",
+ enableResizing: true,
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="Response Status" />
+ ),
+ cell: ({ row }) => {
+ const status = row.original.responseStatus;
+ let variant: "default" | "secondary" | "destructive" | "outline";
+
+ switch (status) {
+ case "REVIEWING":
+ variant = "default";
+ break;
+ case "ACCEPTED":
+ variant = "secondary";
+ break;
+ case "DECLINED":
+ variant = "destructive";
+ break;
+ default:
+ variant = "outline";
+ }
+
+ return <Badge variant={variant}>{status}</Badge>;
+ },
+ size: 150,
+ }
+
+ // 5) 프로젝트 이름 컬럼
+ const projectNameColumn: ColumnDef<RfqWithAll> = {
+ id: "projectName",
+ accessorKey: "projectName",
+ enableResizing: true,
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="Project" />
+ ),
+ cell: ({ row }) => row.original.projectName || "-",
+ size: 150,
+ }
+
+ // 6) RFQ Description 컬럼
+ const descriptionColumn: ColumnDef<RfqWithAll> = {
+ id: "rfqDescription",
+ accessorKey: "rfqDescription",
+ enableResizing: true,
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="Description" />
+ ),
+ cell: ({ row }) => row.original.rfqDescription || "-",
+ size: 200,
+ }
+
+ // 7) Due Date 컬럼
+ const dueDateColumn: ColumnDef<RfqWithAll> = {
+ id: "rfqDueDate",
+ accessorKey: "rfqDueDate",
+ enableResizing: true,
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="Due Date" />
+ ),
+ cell: ({ row }) => {
+ const date = row.original.rfqDueDate;
+ return date ? formatDate(date) : "-";
+ },
+ size: 120,
+ }
+
+ // 8) Last Updated 컬럼
+ const updatedAtColumn: ColumnDef<RfqWithAll> = {
+ id: "respondedAt",
+ accessorKey: "respondedAt",
+ enableResizing: true,
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="Last Updated" />
+ ),
+ cell: ({ row }) => {
+ const date = row.original.respondedAt;
+ return date ? formatDateTime(date) : "-";
+ },
+ size: 150,
+ }
+
+ // 9) Items 컬럼 - 뱃지로 아이템 개수 표시
+ const itemsColumn: ColumnDef<RfqWithAll> = {
+ id: "items",
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="Items" />
+ ),
+ cell: ({ row }) => {
+ const rfq = row.original
+ const count = rfq.items?.length ?? 0
+
+ function handleClick() {
+ setRowAction({ row, type: "items" })
+ }
+
+ return (
+ <Button
+ variant="ghost"
+ size="sm"
+ className="relative h-8 w-8 p-0 group"
+ onClick={handleClick}
+ aria-label={count > 0 ? `View ${count} items` : "No items"}
+ >
+ <Package className="h-4 w-4 text-muted-foreground group-hover:text-primary transition-colors" />
+ {count > 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"
+ >
+ {count}
+ </Badge>
+ )}
+
+ <span className="sr-only">
+ {count > 0 ? `${count} Items` : "No Items"}
+ </span>
+ </Button>
+ )
+ },
+ enableSorting: false,
+ maxSize: 80,
+ }
+
+ // 10) Attachments 컬럼 - 뱃지로 파일 개수 표시
+ const attachmentsColumn: ColumnDef<RfqWithAll> = {
+ id: "attachments",
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="Attachments" />
+ ),
+ cell: ({ row }) => {
+ const attachCount = row.original.attachments?.length ?? 0
+
+ function handleClick(e: React.MouseEvent<HTMLButtonElement>) {
+ e.preventDefault()
+ openAttachmentsSheet(row.original.rfqId)
+ }
+
+ return (
+ <Button
+ variant="ghost"
+ size="sm"
+ className="relative h-8 w-8 p-0 group"
+ onClick={handleClick}
+ aria-label={
+ attachCount > 0 ? `View ${attachCount} files` : "No files"
+ }
+ >
+ <Paperclip className="h-4 w-4 text-muted-foreground group-hover:text-primary transition-colors" />
+ {attachCount > 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"
+ >
+ {attachCount}
+ </Badge>
+ )}
+ <span className="sr-only">
+ {attachCount > 0 ? `${attachCount} Files` : "No Files"}
+ </span>
+ </Button>
+ )
+ },
+ enableSorting: false,
+ maxSize: 80,
+ }
+
+ // 11) Comments 컬럼 - 뱃지로 댓글 개수 표시
+ const commentsColumn: ColumnDef<RfqWithAll> = {
+ id: "comments",
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="Comments" />
+ ),
+ cell: ({ row }) => {
+ const commCount = row.original.comments?.length ?? 0
+
+ function handleClick() {
+ setRowAction({ row, type: "comments" })
+ openCommentSheet(row.original.rfqId)
+ }
+
+ 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,
+ }
+
+ // 최종 컬럼 구성 - TBE/CBE 관련 컬럼 제외
+ return [
+ selectColumn,
+ rfqCodeColumn,
+ responseStatusColumn,
+ projectNameColumn,
+ descriptionColumn,
+ dueDateColumn,
+ itemsColumn,
+ attachmentsColumn,
+ commentsColumn,
+ updatedAtColumn,
+ actionsColumn,
+ ]
+} \ No newline at end of file
diff --git a/lib/tech-vendor-rfq-response/vendor-rfq-table/rfqs-table-toolbar-actions.tsx b/lib/tech-vendor-rfq-response/vendor-rfq-table/rfqs-table-toolbar-actions.tsx
new file mode 100644
index 00000000..1bae99ef
--- /dev/null
+++ b/lib/tech-vendor-rfq-response/vendor-rfq-table/rfqs-table-toolbar-actions.tsx
@@ -0,0 +1,40 @@
+"use client"
+
+import * as React from "react"
+import { type Table } from "@tanstack/react-table"
+import { Download, Upload } from "lucide-react"
+import { toast } from "sonner"
+
+import { exportTableToExcel } from "@/lib/export"
+import { Button } from "@/components/ui/button"
+import { RfqWithAll } from "../types"
+
+
+interface RfqsTableToolbarActionsProps {
+ table: Table<RfqWithAll>
+}
+
+export function RfqsVendorTableToolbarActions({ table }: RfqsTableToolbarActionsProps) {
+
+
+ return (
+ <div className="flex items-center gap-2">
+
+ {/** 4) Export 버튼 */}
+ <Button
+ variant="outline"
+ size="sm"
+ onClick={() =>
+ exportTableToExcel(table, {
+ filename: "tasks",
+ excludeColumns: ["select", "actions"],
+ })
+ }
+ className="gap-2"
+ >
+ <Download className="size-4" aria-hidden="true" />
+ <span className="hidden sm:inline">Export</span>
+ </Button>
+ </div>
+ )
+} \ No newline at end of file
diff --git a/lib/tech-vendor-rfq-response/vendor-rfq-table/rfqs-table.tsx b/lib/tech-vendor-rfq-response/vendor-rfq-table/rfqs-table.tsx
new file mode 100644
index 00000000..2e5ae5dc
--- /dev/null
+++ b/lib/tech-vendor-rfq-response/vendor-rfq-table/rfqs-table.tsx
@@ -0,0 +1,280 @@
+"use client"
+
+import * as React from "react"
+import type {
+ DataTableAdvancedFilterField,
+ DataTableFilterField,
+ DataTableRowAction,
+} from "@/types/table"
+import { useRouter } from "next/navigation"
+
+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 "./rfqs-table-columns"
+import { RfqWithAll } from "../types"
+
+import {
+ fetchRfqAttachments,
+ fetchRfqAttachmentsbyCommentId,
+} from "../../rfqs-tech/service"
+
+import { RfqsVendorTableToolbarActions } from "./rfqs-table-toolbar-actions"
+import { RfqsItemsDialog } from "./ItemsDialog"
+import { RfqAttachmentsSheet } from "./attachment-rfq-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 {
+ id: number
+ commentText: string
+ commentedBy?: number
+ commentedByEmail?: string
+ createdAt?: Date
+ attachments?: {
+ id: number
+ fileName: string
+ filePath: string
+ }[]
+}
+
+export interface ExistingAttachment {
+ id: number
+ fileName: string
+ filePath: string
+ createdAt?: Date
+ vendorId?: number | null
+ size?: number
+}
+
+export interface ExistingItem {
+ id?: number
+ itemCode: string
+ description: string | null
+ quantity: number | null
+ uom: string | null
+}
+
+export function RfqsVendorTable({ promises }: RfqsTableProps) {
+ const { featureFlags } = useFeatureFlags()
+ const { data: session } = useSession() // 세션 정보 가져오기
+
+ // 1) 테이블 데이터( RFQs )
+ const [{ data: responseData, pageCount }] = React.use(promises)
+
+ // 데이터를 RfqWithAll 타입으로 변환 (id 필드 추가)
+ const data: RfqWithAll[] = React.useMemo(() => {
+ return responseData.map(item => ({
+ ...item,
+ id: item.rfqId, // id 필드를 rfqId와 동일하게 설정
+ }));
+ }, [responseData]);
+
+ const router = useRouter()
+
+ // 2) 첨부파일 시트 + 관련 상태
+ const [attachmentsOpen, setAttachmentsOpen] = React.useState(false)
+ const [selectedRfqIdForAttachments, setSelectedRfqIdForAttachments] = React.useState<number | null>(null)
+ const [attachDefault, setAttachDefault] = React.useState<ExistingAttachment[]>([])
+
+ // 3) 코멘트 시트 + 관련 상태
+ const [initialComments, setInitialComments] = React.useState<RfqCommentWithAttachments[]>([])
+ const [commentSheetOpen, setCommentSheetOpen] = React.useState(false)
+ const [selectedRfqIdForComments, setSelectedRfqIdForComments] = React.useState<number | null>(null)
+
+ // 4) rowAction으로 다양한 모달/시트 열기
+ const [rowAction, setRowAction] = React.useState<DataTableRowAction<RfqWithAll> | null>(null)
+
+ // 열리고 닫힐 때마다, rowAction 등을 확인해서 시트 열기/닫기 처리
+ React.useEffect(() => {
+ if (rowAction?.type === "comments" && rowAction?.row.original) {
+ openCommentSheet(rowAction.row.original.id)
+ }
+ }, [rowAction])
+
+ /**
+ * (A) 코멘트 시트를 열기 전에,
+ * DB에서 (rfqId에 해당하는) 코멘트들 + 각 코멘트별 첨부파일을 조회.
+ */
+ const openCommentSheet = React.useCallback(async (rfqId: number) => {
+ setInitialComments([])
+
+ // 여기서 rowAction을 직접 참조하지 않고, 필요한 데이터만 파라미터로 받기
+ const comments = data.find(rfq => rfq.rfqId === rfqId)?.comments || []
+
+ if (comments && comments.length > 0) {
+ const commentWithAttachments = await Promise.all(
+ comments.map(async (c) => {
+ const attachments = await fetchRfqAttachmentsbyCommentId(c.id)
+ return {
+ ...c,
+ commentedBy: c.commentedBy || 1,
+ attachments,
+ }
+ })
+ )
+
+ setInitialComments(commentWithAttachments)
+ }
+
+ setSelectedRfqIdForComments(rfqId)
+ setCommentSheetOpen(true)
+ }, [data]) // data만 의존성으로 추가
+
+ /**
+ * (B) 첨부파일 시트 열기
+ */
+ const openAttachmentsSheet = React.useCallback(async (rfqId: number) => {
+ const list = await fetchRfqAttachments(rfqId)
+ setAttachDefault(list)
+ setSelectedRfqIdForAttachments(rfqId)
+ setAttachmentsOpen(true)
+ }, [])
+
+ // 5) DataTable 컬럼 세팅
+ const columns = React.useMemo(
+ () =>
+ getColumns({
+ setRowAction,
+ router,
+ openAttachmentsSheet,
+ openCommentSheet
+ }),
+ [setRowAction, router, openAttachmentsSheet, openCommentSheet]
+ )
+
+ /**
+ * 간단한 filterFields 예시
+ */
+ const filterFields: DataTableFilterField<RfqWithAll>[] = [
+ {
+ id: "rfqCode",
+ label: "RFQ Code",
+ placeholder: "Filter RFQ Code...",
+ },
+ {
+ id: "projectName",
+ label: "Project",
+ placeholder: "Filter Project...",
+ },
+ {
+ id: "rfqDescription",
+ label: "Description",
+ placeholder: "Filter Description...",
+ },
+ ]
+
+ /**
+ * Advanced filter fields 예시
+ */
+ const advancedFilterFields: DataTableAdvancedFilterField<RfqWithAll>[] = [
+ {
+ id: "rfqCode",
+ label: "RFQ Code",
+ type: "text",
+ },
+ {
+ id: "rfqDescription",
+ label: "Description",
+ type: "text",
+ },
+ {
+ id: "projectCode",
+ label: "Project Code",
+ type: "text",
+ },
+ {
+ id: "projectName",
+ label: "Project Name",
+ type: "text",
+ },
+ {
+ id: "rfqDueDate",
+ label: "Due Date",
+ type: "date",
+ },
+ {
+ id: "responseStatus",
+ label: "Response Status",
+ type: "select",
+ options: [
+ { label: "Reviewing", value: "REVIEWING" },
+ { label: "Accepted", value: "ACCEPTED" },
+ { label: "Declined", value: "DECLINED" },
+ ],
+ }
+ ]
+
+ // useDataTable() 훅 -> pagination, sorting 등 관리
+ const { table } = useDataTable({
+ data,
+ columns,
+ pageCount,
+ filterFields,
+ enablePinning: true,
+ enableAdvancedFilter: true,
+ initialState: {
+ sorting: [{ id: "respondedAt", desc: true }],
+ columnPinning: { right: ["actions"] },
+ },
+ getRowId: (originalRow) => String(originalRow.id),
+ shallow: false,
+ 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}>
+ <DataTableAdvancedToolbar
+ table={table}
+ filterFields={advancedFilterFields}
+ shallow={false}
+ >
+ <RfqsVendorTableToolbarActions table={table} />
+ </DataTableAdvancedToolbar>
+ </DataTable>
+
+ {/* 1) 아이템 목록 Dialog */}
+ {rowAction?.type === "items" && rowAction?.row.original && (
+ <RfqsItemsDialog
+ open={true}
+ onOpenChange={() => setRowAction(null)}
+ rfq={rowAction.row.original}
+ />
+ )}
+
+ {/* 2) 코멘트 시트 */}
+ {selectedRfqIdForComments && (
+ <CommentSheet
+ open={commentSheetOpen}
+ onOpenChange={setCommentSheetOpen}
+ initialComments={initialComments}
+ rfqId={selectedRfqIdForComments}
+ vendorId={currentVendorId??0}
+ currentUserId={currentUserId}
+ />
+ )}
+
+ {/* 3) 첨부파일 시트 */}
+ <RfqAttachmentsSheet
+ open={attachmentsOpen}
+ onOpenChange={setAttachmentsOpen}
+ rfqId={selectedRfqIdForAttachments ?? 0}
+ attachments={attachDefault}
+ />
+ </>
+ )
+} \ No newline at end of file