summaryrefslogtreecommitdiff
path: root/lib/rfqs-tech/tbe-table
diff options
context:
space:
mode:
Diffstat (limited to 'lib/rfqs-tech/tbe-table')
-rw-r--r--lib/rfqs-tech/tbe-table/comments-sheet.tsx325
-rw-r--r--lib/rfqs-tech/tbe-table/feature-flags-provider.tsx108
-rw-r--r--lib/rfqs-tech/tbe-table/file-dialog.tsx139
-rw-r--r--lib/rfqs-tech/tbe-table/invite-vendors-dialog.tsx227
-rw-r--r--lib/rfqs-tech/tbe-table/tbe-result-dialog.tsx208
-rw-r--r--lib/rfqs-tech/tbe-table/tbe-table-columns.tsx360
-rw-r--r--lib/rfqs-tech/tbe-table/tbe-table-toolbar-actions.tsx67
-rw-r--r--lib/rfqs-tech/tbe-table/tbe-table.tsx243
-rw-r--r--lib/rfqs-tech/tbe-table/vendor-contact-dialog.tsx71
-rw-r--r--lib/rfqs-tech/tbe-table/vendor-contact/vendor-contact-table-column.tsx70
-rw-r--r--lib/rfqs-tech/tbe-table/vendor-contact/vendor-contact-table.tsx89
11 files changed, 1907 insertions, 0 deletions
diff --git a/lib/rfqs-tech/tbe-table/comments-sheet.tsx b/lib/rfqs-tech/tbe-table/comments-sheet.tsx
new file mode 100644
index 00000000..6efd631f
--- /dev/null
+++ b/lib/rfqs-tech/tbe-table/comments-sheet.tsx
@@ -0,0 +1,325 @@
+"use client"
+
+import * as React from "react"
+import { useForm, useFieldArray } from "react-hook-form"
+import { z } from "zod"
+import { zodResolver } from "@hookform/resolvers/zod"
+import { Download, X, Loader2 } from "lucide-react"
+import prettyBytes from "pretty-bytes"
+import { toast } from "sonner"
+
+import {
+ Sheet,
+ SheetClose,
+ SheetContent,
+ SheetDescription,
+ SheetFooter,
+ SheetHeader,
+ SheetTitle,
+} from "@/components/ui/sheet"
+import { Button } from "@/components/ui/button"
+import {
+ Form,
+ FormControl,
+ FormField,
+ FormItem,
+ FormLabel,
+ FormMessage,
+} from "@/components/ui/form"
+import { Textarea } from "@/components/ui/textarea"
+import {
+ Dropzone,
+ DropzoneZone,
+ DropzoneUploadIcon,
+ DropzoneTitle,
+ DropzoneDescription,
+ DropzoneInput,
+} from "@/components/ui/dropzone"
+import {
+ Table,
+ TableHeader,
+ TableRow,
+ TableHead,
+ TableBody,
+ TableCell,
+} from "@/components/ui/table"
+
+import { createRfqCommentWithAttachments } from "../service"
+import { formatDate } from "@/lib/utils"
+
+
+export interface TbeComment {
+ id: number
+ commentText: string
+ commentedBy?: number
+ commentedByEmail?: string
+ createdAt?: Date
+ attachments?: {
+ id: number
+ fileName: string
+ filePath: string
+ }[]
+}
+
+// 1) props 정의
+interface CommentSheetProps extends React.ComponentPropsWithRef<typeof Sheet> {
+ initialComments?: TbeComment[]
+ currentUserId: number
+ rfqId: number
+ tbeId: number
+ vendorId: number
+ onCommentsUpdated?: (comments: TbeComment[]) => void
+ isLoading?: boolean // New prop
+}
+
+// 2) 폼 스키마
+const commentFormSchema = z.object({
+ commentText: z.string().min(1, "댓글을 입력하세요."),
+ newFiles: z.array(z.any()).optional(), // File[]
+})
+type CommentFormValues = z.infer<typeof commentFormSchema>
+
+const MAX_FILE_SIZE = 30e6 // 30MB
+
+export function CommentSheet({
+ rfqId,
+ vendorId,
+ initialComments = [],
+ currentUserId,
+ tbeId,
+ onCommentsUpdated,
+ isLoading = false, // Default to false
+ ...props
+}: CommentSheetProps) {
+ console.log("tbeId", tbeId)
+
+ const [comments, setComments] = React.useState<TbeComment[]>(initialComments)
+ const [isPending, startTransition] = React.useTransition()
+
+ React.useEffect(() => {
+ setComments(initialComments)
+ }, [initialComments])
+
+ const form = useForm<CommentFormValues>({
+ resolver: zodResolver(commentFormSchema),
+ defaultValues: {
+ commentText: "",
+ newFiles: [],
+ },
+ })
+
+ const { fields: newFileFields, append, remove } = useFieldArray({
+ control: form.control,
+ name: "newFiles",
+ })
+
+ // (A) 기존 코멘트 렌더링
+ function renderExistingComments() {
+
+ if (isLoading) {
+ return (
+ <div className="flex justify-center items-center h-32">
+ <Loader2 className="h-4 w-4 animate-spin text-muted-foreground" />
+ <span className="ml-2 text-sm text-muted-foreground">Loading comments...</span>
+ </div>
+ )
+ }
+
+ if (comments.length === 0) {
+ return <p className="text-sm text-muted-foreground">No comments yet</p>
+ }
+ return (
+ <Table>
+ <TableHeader>
+ <TableRow>
+ <TableHead className="w-1/2">Comment</TableHead>
+ <TableHead>Attachments</TableHead>
+ <TableHead>Created At</TableHead>
+ <TableHead>Created By</TableHead>
+ </TableRow>
+ </TableHeader>
+ <TableBody>
+ {comments.map((c) => (
+ <TableRow key={c.id}>
+ <TableCell>{c.commentText}</TableCell>
+ <TableCell>
+ {!c.attachments?.length && (
+ <span className="text-sm text-muted-foreground">No files</span>
+ )}
+ {c.attachments?.length && (
+ <div className="flex flex-col gap-1">
+ {c.attachments.map((att) => (
+ <div key={att.id} className="flex items-center gap-2">
+ <a
+ href={`/api/rfq-download?path=${encodeURIComponent(att.filePath)}`}
+ download
+ target="_blank"
+ rel="noreferrer"
+ className="inline-flex items-center gap-1 text-blue-600 underline"
+ >
+ <Download className="h-4 w-4" />
+ {att.fileName}
+ </a>
+ </div>
+ ))}
+ </div>
+ )}
+ </TableCell>
+ <TableCell> {c.createdAt ? formatDate(c.createdAt) : "-"}</TableCell>
+ <TableCell>{c.commentedByEmail ?? "-"}</TableCell>
+ </TableRow>
+ ))}
+ </TableBody>
+ </Table>
+ )
+ }
+
+ // (B) 파일 드롭
+ function handleDropAccepted(files: File[]) {
+ append(files)
+ }
+
+ // (C) Submit
+ async function onSubmit(data: CommentFormValues) {
+ if (!rfqId) return
+ startTransition(async () => {
+ try {
+ console.log("rfqId", rfqId)
+ console.log("vendorId", vendorId)
+ console.log("tbeId", tbeId)
+ console.log("currentUserId", currentUserId)
+ const res = await createRfqCommentWithAttachments({
+ rfqId,
+ vendorId,
+ commentText: data.commentText,
+ commentedBy: currentUserId,
+ evaluationId: tbeId,
+ cbeId: null,
+ files: data.newFiles,
+ })
+
+ if (!res.ok) {
+ throw new Error("Failed to create comment")
+ }
+
+ toast.success("Comment created")
+
+ // 임시로 새 코멘트 추가
+ const newComment: TbeComment = {
+ id: res.commentId, // 서버 응답
+ commentText: data.commentText,
+ commentedBy: currentUserId,
+ createdAt: res.createdAt,
+ attachments:
+ data.newFiles?.map((f) => ({
+ id: Math.floor(Math.random() * 1e6),
+ fileName: f.name,
+ filePath: "/uploads/" + f.name,
+ })) || [],
+ }
+ setComments((prev) => [...prev, newComment])
+ onCommentsUpdated?.([...comments, newComment])
+
+ form.reset()
+ } catch (err: any) {
+ console.error(err)
+ toast.error("Error: " + err.message)
+ }
+ })
+ }
+
+ return (
+ <Sheet {...props}>
+ <SheetContent className="flex flex-col gap-6 sm:max-w-lg">
+ <SheetHeader className="text-left">
+ <SheetTitle>Comments</SheetTitle>
+ <SheetDescription>
+ 필요시 첨부파일과 함께 문의/코멘트를 남길 수 있습니다.
+ </SheetDescription>
+ </SheetHeader>
+
+ <div className="max-h-[300px] overflow-y-auto">{renderExistingComments()}</div>
+
+ <Form {...form}>
+ <form onSubmit={form.handleSubmit(onSubmit)} className="flex flex-col gap-4">
+ <FormField
+ control={form.control}
+ name="commentText"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>New Comment</FormLabel>
+ <FormControl>
+ <Textarea placeholder="Enter your comment..." {...field} />
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+
+ <Dropzone
+ maxSize={MAX_FILE_SIZE}
+ onDropAccepted={handleDropAccepted}
+ onDropRejected={(rej) => {
+ toast.error("File rejected: " + (rej[0]?.file?.name || ""))
+ }}
+ >
+ {({ maxSize }) => (
+ <DropzoneZone className="flex justify-center">
+ <DropzoneInput />
+ <div className="flex items-center gap-6">
+ <DropzoneUploadIcon />
+ <div className="grid gap-0.5">
+ <DropzoneTitle>Drop to attach files</DropzoneTitle>
+ <DropzoneDescription>
+ Max size: {prettyBytes(maxSize || 0)}
+ </DropzoneDescription>
+ </div>
+ </div>
+ </DropzoneZone>
+ )}
+ </Dropzone>
+
+ {newFileFields.length > 0 && (
+ <div className="flex flex-col gap-2">
+ {newFileFields.map((field, idx) => {
+ const file = form.getValues(`newFiles.${idx}`)
+ if (!file) return null
+ return (
+ <div
+ key={field.id}
+ className="flex items-center justify-between border rounded p-2"
+ >
+ <span className="text-sm">
+ {file.name} ({prettyBytes(file.size)})
+ </span>
+ <Button
+ variant="ghost"
+ size="icon"
+ type="button"
+ onClick={() => remove(idx)}
+ >
+ <X className="h-4 w-4" />
+ </Button>
+ </div>
+ )
+ })}
+ </div>
+ )}
+
+ <SheetFooter className="gap-2 pt-4">
+ <SheetClose asChild>
+ <Button type="button" variant="outline">
+ Cancel
+ </Button>
+ </SheetClose>
+ <Button disabled={isPending}>
+ {isPending && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
+ Save
+ </Button>
+ </SheetFooter>
+ </form>
+ </Form>
+ </SheetContent>
+ </Sheet>
+ )
+} \ No newline at end of file
diff --git a/lib/rfqs-tech/tbe-table/feature-flags-provider.tsx b/lib/rfqs-tech/tbe-table/feature-flags-provider.tsx
new file mode 100644
index 00000000..81131894
--- /dev/null
+++ b/lib/rfqs-tech/tbe-table/feature-flags-provider.tsx
@@ -0,0 +1,108 @@
+"use client"
+
+import * as React from "react"
+import { useQueryState } from "nuqs"
+
+import { dataTableConfig, type DataTableConfig } from "@/config/data-table"
+import { cn } from "@/lib/utils"
+import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group"
+import {
+ Tooltip,
+ TooltipContent,
+ TooltipTrigger,
+} from "@/components/ui/tooltip"
+
+type FeatureFlagValue = DataTableConfig["featureFlags"][number]["value"]
+
+interface FeatureFlagsContextProps {
+ featureFlags: FeatureFlagValue[]
+ setFeatureFlags: (value: FeatureFlagValue[]) => void
+}
+
+const FeatureFlagsContext = React.createContext<FeatureFlagsContextProps>({
+ featureFlags: [],
+ setFeatureFlags: () => {},
+})
+
+export function useFeatureFlags() {
+ const context = React.useContext(FeatureFlagsContext)
+ if (!context) {
+ throw new Error(
+ "useFeatureFlags must be used within a FeatureFlagsProvider"
+ )
+ }
+ return context
+}
+
+interface FeatureFlagsProviderProps {
+ children: React.ReactNode
+}
+
+export function FeatureFlagsProvider({ children }: FeatureFlagsProviderProps) {
+ const [featureFlags, setFeatureFlags] = useQueryState<FeatureFlagValue[]>(
+ "flags",
+ {
+ defaultValue: [],
+ parse: (value) => value.split(",") as FeatureFlagValue[],
+ serialize: (value) => value.join(","),
+ eq: (a, b) =>
+ a.length === b.length && a.every((value, index) => value === b[index]),
+ clearOnDefault: true,
+ shallow: false,
+ }
+ )
+
+ return (
+ <FeatureFlagsContext.Provider
+ value={{
+ featureFlags,
+ setFeatureFlags: (value) => void setFeatureFlags(value),
+ }}
+ >
+ <div className="w-full overflow-x-auto">
+ <ToggleGroup
+ type="multiple"
+ variant="outline"
+ size="sm"
+ value={featureFlags}
+ onValueChange={(value: FeatureFlagValue[]) => setFeatureFlags(value)}
+ className="w-fit gap-0"
+ >
+ {dataTableConfig.featureFlags.map((flag, index) => (
+ <Tooltip key={flag.value}>
+ <ToggleGroupItem
+ value={flag.value}
+ className={cn(
+ "gap-2 whitespace-nowrap rounded-none px-3 text-xs data-[state=on]:bg-accent/70 data-[state=on]:hover:bg-accent/90",
+ {
+ "rounded-l-sm border-r-0": index === 0,
+ "rounded-r-sm":
+ index === dataTableConfig.featureFlags.length - 1,
+ }
+ )}
+ asChild
+ >
+ <TooltipTrigger>
+ <flag.icon className="size-3.5 shrink-0" aria-hidden="true" />
+ {flag.label}
+ </TooltipTrigger>
+ </ToggleGroupItem>
+ <TooltipContent
+ align="start"
+ side="bottom"
+ sideOffset={6}
+ className="flex max-w-60 flex-col space-y-1.5 border bg-background py-2 font-semibold text-foreground"
+ >
+ <div>{flag.tooltipTitle}</div>
+ <div className="text-xs text-muted-foreground">
+ {flag.tooltipDescription}
+ </div>
+ </TooltipContent>
+ </Tooltip>
+ ))}
+ </ToggleGroup>
+ </div>
+ {children}
+ </FeatureFlagsContext.Provider>
+ )
+}
diff --git a/lib/rfqs-tech/tbe-table/file-dialog.tsx b/lib/rfqs-tech/tbe-table/file-dialog.tsx
new file mode 100644
index 00000000..712f7ff6
--- /dev/null
+++ b/lib/rfqs-tech/tbe-table/file-dialog.tsx
@@ -0,0 +1,139 @@
+"use client"
+
+import * as React from "react"
+import { Download, X } from "lucide-react"
+import { toast } from "sonner"
+
+import { getErrorMessage } from "@/lib/handle-error"
+import { formatDateTime } from "@/lib/utils"
+import { Button } from "@/components/ui/button"
+import {
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogHeader,
+ DialogTitle,
+} from "@/components/ui/dialog"
+
+import {
+ FileList,
+ FileListItem,
+ FileListIcon,
+ FileListInfo,
+ FileListName,
+ FileListDescription,
+ FileListAction,
+} from "@/components/ui/file-list"
+import { getTbeFilesForVendor, getTbeSubmittedFiles } from "../service"
+
+interface TBEFileDialogProps {
+ isOpen: boolean
+ onOpenChange: (open: boolean) => void
+ tbeId: number
+ vendorId: number
+ rfqId: number
+ onRefresh?: () => void
+}
+
+export function TBEFileDialog({
+ isOpen,
+ onOpenChange,
+ vendorId,
+ rfqId,
+ onRefresh,
+}: TBEFileDialogProps) {
+ const [submittedFiles, setSubmittedFiles] = React.useState<any[]>([])
+ const [isFetchingFiles, setIsFetchingFiles] = React.useState(false)
+
+
+ // Fetch submitted files when dialog opens
+ React.useEffect(() => {
+ if (isOpen && rfqId && vendorId) {
+ fetchSubmittedFiles()
+ }
+ }, [isOpen, rfqId, vendorId])
+
+ // Fetch submitted files using the service function
+ const fetchSubmittedFiles = async () => {
+ if (!rfqId || !vendorId) return
+
+ setIsFetchingFiles(true)
+ try {
+ const { files, error } = await getTbeFilesForVendor(rfqId, vendorId)
+
+ if (error) {
+ throw new Error(error)
+ }
+
+ setSubmittedFiles(files)
+ } catch (error) {
+ toast.error("Failed to load files: " + getErrorMessage(error))
+ } finally {
+ setIsFetchingFiles(false)
+ }
+ }
+
+ // Download submitted file
+ const downloadSubmittedFile = async (file: any) => {
+ try {
+ const response = await fetch(`/api/tbe-download?path=${encodeURIComponent(file.filePath)}`)
+ if (!response.ok) {
+ throw new Error("Failed to download file")
+ }
+
+ const blob = await response.blob()
+ const url = window.URL.createObjectURL(blob)
+ const a = document.createElement("a")
+ a.href = url
+ a.download = file.fileName
+ document.body.appendChild(a)
+ a.click()
+ window.URL.revokeObjectURL(url)
+ document.body.removeChild(a)
+ } catch (error) {
+ toast.error("Failed to download file: " + getErrorMessage(error))
+ }
+ }
+
+ return (
+ <Dialog open={isOpen} onOpenChange={onOpenChange}>
+ <DialogContent className="sm:max-w-lg">
+ <DialogHeader>
+ <DialogTitle>TBE 응답 파일</DialogTitle>
+ <DialogDescription>제출된 파일 목록을 확인하고 다운로드하세요.</DialogDescription>
+ </DialogHeader>
+
+ {/* 제출된 파일 목록 */}
+ {isFetchingFiles ? (
+ <div className="flex justify-center items-center py-8">
+ <div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary"></div>
+ </div>
+ ) : submittedFiles.length > 0 ? (
+ <div className="grid gap-2">
+ <FileList>
+ {submittedFiles.map((file) => (
+ <FileListItem key={file.id} className="flex items-center justify-between gap-3">
+ <div className="flex items-center gap-3 flex-1">
+ <FileListIcon className="flex-shrink-0" />
+ <FileListInfo className="flex-1 min-w-0">
+ <FileListName className="text-sm font-medium truncate">{file.fileName}</FileListName>
+ <FileListDescription className="text-xs text-muted-foreground">
+ {file.uploadedAt ? formatDateTime(file.uploadedAt) : ""}
+ </FileListDescription>
+ </FileListInfo>
+ </div>
+ <FileListAction className="flex-shrink-0 ml-2" onClick={() => downloadSubmittedFile(file)}>
+ <Download className="h-4 w-4" />
+ <span className="sr-only">파일 다운로드</span>
+ </FileListAction>
+ </FileListItem>
+ ))}
+ </FileList>
+ </div>
+ ) : (
+ <div className="text-center py-8 text-muted-foreground">제출된 파일이 없습니다.</div>
+ )}
+ </DialogContent>
+ </Dialog>
+ )
+} \ No newline at end of file
diff --git a/lib/rfqs-tech/tbe-table/invite-vendors-dialog.tsx b/lib/rfqs-tech/tbe-table/invite-vendors-dialog.tsx
new file mode 100644
index 00000000..f7aa957c
--- /dev/null
+++ b/lib/rfqs-tech/tbe-table/invite-vendors-dialog.tsx
@@ -0,0 +1,227 @@
+"use client"
+
+import * as React from "react"
+import { type Row } from "@tanstack/react-table"
+import { Loader, Send } from "lucide-react"
+import { toast } from "sonner"
+
+import { useMediaQuery } from "@/hooks/use-media-query"
+import { Button } from "@/components/ui/button"
+import {
+ Dialog,
+ DialogClose,
+ DialogContent,
+ DialogDescription,
+ DialogFooter,
+ DialogHeader,
+ DialogTitle,
+ DialogTrigger,
+} from "@/components/ui/dialog"
+import {
+ Drawer,
+ DrawerClose,
+ DrawerContent,
+ DrawerDescription,
+ DrawerFooter,
+ DrawerHeader,
+ DrawerTitle,
+ DrawerTrigger,
+} from "@/components/ui/drawer"
+
+import { Input } from "@/components/ui/input"
+
+import { VendorWithTbeFields } from "@/config/vendorTbeColumnsConfig"
+import { inviteTbeVendorsAction } from "../service"
+import { ScrollArea } from "@/components/ui/scroll-area"
+import { Badge } from "@/components/ui/badge"
+import { Label } from "@/components/ui/label"
+
+interface InviteVendorsDialogProps
+ extends React.ComponentPropsWithoutRef<typeof Dialog> {
+ vendors: Row<VendorWithTbeFields>["original"][]
+ rfqId: number
+ showTrigger?: boolean
+ onSuccess?: () => void
+}
+
+export function InviteVendorsDialog({
+ vendors,
+ rfqId,
+ showTrigger = true,
+ onSuccess,
+ ...props
+}: InviteVendorsDialogProps) {
+ const [isInvitePending, startInviteTransition] = React.useTransition()
+
+
+ // multiple 파일을 받을 state
+ const [files, setFiles] = React.useState<FileList | null>(null)
+
+ // 미디어쿼리 (desktop 여부)
+ const isDesktop = useMediaQuery("(min-width: 640px)")
+
+ function onInvite() {
+ startInviteTransition(async () => {
+ // 파일이 선택되지 않았다면 에러
+ if (!files || files.length === 0) {
+ toast.error("Please attach TBE files before inviting.")
+ return
+ }
+
+ // FormData 생성
+ const formData = new FormData()
+ formData.append("rfqId", String(rfqId))
+
+ console.log("Invite Debug:", {
+ rfqId,
+ vendors,
+ files
+ })
+
+ vendors.forEach((vendor) => {
+ formData.append("vendorIds[]", String(vendor.id))
+ })
+
+ // multiple 파일
+ for (let i = 0; i < files.length; i++) {
+ formData.append("tbeFiles", files[i]) // key는 동일하게 "tbeFiles"
+ }
+
+ // 서버 액션 호출
+ const { error } = await inviteTbeVendorsAction(formData)
+
+ if (error) {
+ toast.error(error)
+ return
+ }
+
+ // 성공
+ props.onOpenChange?.(false)
+ toast.success("Vendors invited with TBE!")
+ onSuccess?.()
+ })
+ }
+
+ // 파일 선택 UI
+ const fileInput = (
+<>
+ <div className="space-y-2">
+ <Label>선택된 협력업체 ({vendors.length})</Label>
+ <ScrollArea className="h-20 border rounded-md p-2">
+ <div className="flex flex-wrap gap-2">
+ {vendors.map((vendor, index) => (
+ <Badge key={index} variant="secondary" className="py-1">
+ {vendor.vendorName || `협력업체 #${vendor.vendorCode}`}
+ </Badge>
+ ))}
+ </div>
+ </ScrollArea>
+ <p className="text-[0.8rem] font-medium text-muted-foreground">
+ 선택된 모든 협력업체의 등록된 연락처에게 TBE 평가 알림이 전송됩니다.
+ </p>
+ </div>
+
+ <div className="mb-4">
+ <label className="mb-2 block font-medium">TBE Sheets</label>
+ <Input
+ type="file"
+ multiple
+ onChange={(e) => {
+ setFiles(e.target.files)
+ }}
+ />
+ </div>
+ </>
+ )
+
+ // Desktop Dialog
+ if (isDesktop) {
+ return (
+ <Dialog {...props}>
+ {showTrigger ? (
+ <DialogTrigger asChild>
+ <Button variant="outline" size="sm">
+ <Send className="mr-2 size-4" aria-hidden="true" />
+ TBE 평가 생성 ({vendors.length})
+ </Button>
+ </DialogTrigger>
+ ) : null}
+ <DialogContent>
+ <DialogHeader>
+ <DialogTitle>TBE 평가 시트 전송</DialogTitle>
+ <DialogDescription>
+ 선택한 {vendors.length}개 협력업체에 대한 기술 평가 시트와 알림을 전송합니다. 파일 첨부가 필수이므로 파일을 첨부해야지 버튼이 활성화됩니다.
+ </DialogDescription>
+ </DialogHeader>
+
+ {/* 파일 첨부 */}
+ {fileInput}
+
+ <DialogFooter className="gap-2 sm:space-x-0">
+ <DialogClose asChild>
+ <Button variant="outline">Cancel</Button>
+ </DialogClose>
+ <Button
+ aria-label="Invite selected rows"
+ variant="destructive"
+ onClick={onInvite}
+ // 파일이 없거나 초대 진행중이면 비활성화
+ disabled={isInvitePending || !files || files.length === 0}
+ >
+ {isInvitePending && (
+ <Loader
+ className="mr-2 size-4 animate-spin"
+ aria-hidden="true"
+ />
+ )}
+ Invite
+ </Button>
+ </DialogFooter>
+ </DialogContent>
+ </Dialog>
+ )
+ }
+
+ // Mobile Drawer
+ return (
+ <Drawer {...props}>
+ {showTrigger ? (
+ <DrawerTrigger asChild>
+ <Button variant="outline" size="sm">
+ <Send className="mr-2 size-4" aria-hidden="true" />
+ Invite ({vendors.length})
+ </Button>
+ </DrawerTrigger>
+ ) : null}
+ <DrawerContent>
+ <DrawerHeader>
+ <DialogTitle>TBE 평가 시트 전송</DialogTitle>
+ <DialogDescription>
+ 선택한 {vendors.length}개 협력업체에 대한 기술 평가 시트와 알림을 전송합니다. 파일 첨부가 필수이므로 파일을 첨부해야지 버튼이 활성화됩니다.
+ </DialogDescription>
+ </DrawerHeader>
+
+ {/* 파일 첨부 */}
+ {fileInput}
+
+ <DrawerFooter className="gap-2 sm:space-x-0">
+ <DrawerClose asChild>
+ <Button variant="outline">Cancel</Button>
+ </DrawerClose>
+ <Button
+ aria-label="Invite selected rows"
+ variant="destructive"
+ onClick={onInvite}
+ // 파일이 없거나 초대 진행중이면 비활성화
+ disabled={isInvitePending || !files || files.length === 0}
+ >
+ {isInvitePending && (
+ <Loader className="mr-2 size-4 animate-spin" aria-hidden="true" />
+ )}
+ Invite
+ </Button>
+ </DrawerFooter>
+ </DrawerContent>
+ </Drawer>
+ )
+} \ No newline at end of file
diff --git a/lib/rfqs-tech/tbe-table/tbe-result-dialog.tsx b/lib/rfqs-tech/tbe-table/tbe-result-dialog.tsx
new file mode 100644
index 00000000..6bd8a6a7
--- /dev/null
+++ b/lib/rfqs-tech/tbe-table/tbe-result-dialog.tsx
@@ -0,0 +1,208 @@
+"use client"
+
+import * as React from "react"
+import { toast } from "sonner"
+
+import { Badge } from "@/components/ui/badge"
+import { Button } from "@/components/ui/button"
+import {
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogFooter,
+ DialogHeader,
+ DialogTitle,
+} from "@/components/ui/dialog"
+import {
+ Select,
+ SelectContent,
+ SelectItem,
+ SelectTrigger,
+ SelectValue,
+} from "@/components/ui/select"
+import { Textarea } from "@/components/ui/textarea"
+import { Label } from "@/components/ui/label"
+import { VendorWithTbeFields } from "@/config/vendorTbeColumnsConfig"
+import { getErrorMessage } from "@/lib/handle-error"
+import { saveTbeResult } from "../service"
+
+// Define the props for the TbeResultDialog component
+interface TbeResultDialogProps {
+ open: boolean
+ onOpenChange: (open: boolean) => void
+ tbe: VendorWithTbeFields | null
+ onRefresh?: () => void
+}
+
+// Define TBE result options
+const TBE_RESULT_OPTIONS = [
+ { value: "pass", label: "Pass", badgeVariant: "default" },
+ { value: "non-pass", label: "Non-Pass", badgeVariant: "destructive" },
+ { value: "conditional pass", label: "Conditional Pass", badgeVariant: "secondary" },
+] as const
+
+type TbeResultOption = typeof TBE_RESULT_OPTIONS[number]["value"]
+
+export function TbeResultDialog({
+ open,
+ onOpenChange,
+ tbe,
+ onRefresh,
+}: TbeResultDialogProps) {
+ // Initialize state for form inputs
+ const [result, setResult] = React.useState<TbeResultOption | "">("")
+ const [note, setNote] = React.useState("")
+ const [isSubmitting, setIsSubmitting] = React.useState(false)
+
+ // Update form values when the tbe prop changes
+ React.useEffect(() => {
+ if (tbe) {
+ setResult((tbe.tbeResult as TbeResultOption) || "")
+ setNote(tbe.tbeNote || "")
+ }
+ }, [tbe])
+
+ // Reset form when dialog closes
+ React.useEffect(() => {
+ if (!open) {
+ // Small delay to avoid visual glitches when dialog is closing
+ const timer = setTimeout(() => {
+ if (!tbe) {
+ setResult("")
+ setNote("")
+ }
+ }, 300)
+ return () => clearTimeout(timer)
+ }
+ }, [open, tbe])
+
+ // Handle form submission with server action
+ const handleSubmit = async (e: React.FormEvent) => {
+ e.preventDefault()
+
+ if (!tbe || !result) return
+
+ setIsSubmitting(true)
+
+ try {
+ // Call the server action to save the TBE result
+ const response = await saveTbeResult({
+ id: tbe.tbeId ?? 0, // This is the id in the rfq_evaluations table
+ vendorId: tbe.vendorId, // This is the vendorId in the rfq_evaluations table
+ result: result, // The selected evaluation result
+ notes: note, // The evaluation notes
+ })
+
+ if (!response.success) {
+ throw new Error(response.message || "Failed to save TBE result")
+ }
+
+ // Show success toast
+ toast.success("TBE result saved successfully")
+
+ // Close the dialog
+ onOpenChange(false)
+
+ // Refresh the data if refresh callback is provided
+ if (onRefresh) {
+ onRefresh()
+ }
+ } catch (error) {
+ // Show error toast
+ toast.error(`Failed to save: ${getErrorMessage(error)}`)
+ } finally {
+ setIsSubmitting(false)
+ }
+ }
+
+ // Find the selected result option
+ const selectedOption = TBE_RESULT_OPTIONS.find(option => option.value === result)
+
+ return (
+ <Dialog open={open} onOpenChange={onOpenChange}>
+ <DialogContent className="sm:max-w-[500px]">
+ <DialogHeader>
+ <DialogTitle className="text-xl font-semibold">
+ {tbe?.tbeResult ? "Edit TBE Result" : "Enter TBE Result"}
+ </DialogTitle>
+ {tbe && (
+ <DialogDescription className="text-sm text-muted-foreground mt-1">
+ <span className="flex flex-col gap-1">
+ <span>
+ <strong>Vendor:</strong> {tbe.vendorName}
+ </span>
+ <span>
+ <strong>RFQ Code:</strong> {tbe.rfqCode}
+ </span>
+ {tbe.email && (
+ <span>
+ <strong>Email:</strong> {tbe.email}
+ </span>
+ )}
+ </span>
+ </DialogDescription>
+ )}
+ </DialogHeader>
+
+ <form onSubmit={handleSubmit} className="space-y-6 py-2">
+ <div className="space-y-2">
+ <Label htmlFor="tbe-result" className="text-sm font-medium">
+ Evaluation Result
+ </Label>
+ <Select
+ value={result}
+ onValueChange={(value) => setResult(value as TbeResultOption)}
+ required
+ >
+ <SelectTrigger id="tbe-result" className="w-full">
+ <SelectValue placeholder="Select a result" />
+ </SelectTrigger>
+ <SelectContent>
+ {TBE_RESULT_OPTIONS.map((option) => (
+ <SelectItem key={option.value} value={option.value}>
+ <div className="flex items-center">
+ <Badge variant={option.badgeVariant as any} className="mr-2">
+ {option.label}
+ </Badge>
+ </div>
+ </SelectItem>
+ ))}
+ </SelectContent>
+ </Select>
+ </div>
+
+ <div className="space-y-2">
+ <Label htmlFor="tbe-note" className="text-sm font-medium">
+ Evaluation Note
+ </Label>
+ <Textarea
+ id="tbe-note"
+ placeholder="Enter evaluation notes..."
+ value={note}
+ onChange={(e) => setNote(e.target.value)}
+ className="min-h-[120px] resize-y"
+ />
+ </div>
+
+ <DialogFooter className="gap-2 sm:gap-0">
+ <Button
+ type="button"
+ variant="outline"
+ onClick={() => onOpenChange(false)}
+ disabled={isSubmitting}
+ >
+ Cancel
+ </Button>
+ <Button
+ type="submit"
+ disabled={!result || isSubmitting}
+ className="min-w-[100px]"
+ >
+ {isSubmitting ? "Saving..." : "Save"}
+ </Button>
+ </DialogFooter>
+ </form>
+ </DialogContent>
+ </Dialog>
+ )
+} \ No newline at end of file
diff --git a/lib/rfqs-tech/tbe-table/tbe-table-columns.tsx b/lib/rfqs-tech/tbe-table/tbe-table-columns.tsx
new file mode 100644
index 00000000..aecbcdb2
--- /dev/null
+++ b/lib/rfqs-tech/tbe-table/tbe-table-columns.tsx
@@ -0,0 +1,360 @@
+"use client"
+
+import * as React from "react"
+import { type DataTableRowAction } from "@/types/table"
+import { type ColumnDef } from "@tanstack/react-table"
+import { Download, Ellipsis, MessageSquare } from "lucide-react"
+import { toast } from "sonner"
+
+import { getErrorMessage } from "@/lib/handle-error"
+import { formatDate } from "@/lib/utils"
+import { Badge } from "@/components/ui/badge"
+import { Button } from "@/components/ui/button"
+import { Checkbox } from "@/components/ui/checkbox"
+
+import { DataTableColumnHeaderSimple } from "@/components/data-table/data-table-column-simple-header"
+import { useRouter } from "next/navigation"
+
+import {
+ vendorTbeColumnsConfig,
+ VendorWithTbeFields,
+} from "@/config/vendorTbeColumnsConfig"
+
+type NextRouter = ReturnType<typeof useRouter>
+
+interface GetColumnsProps {
+ setRowAction: React.Dispatch<
+ React.SetStateAction<DataTableRowAction<VendorWithTbeFields> | null>
+ >
+ router: NextRouter
+ openCommentSheet: (vendorId: number) => void
+ openFilesDialog: (tbeId:number , vendorId: number) => void
+ openVendorContactsDialog: (vendorId: number, vendor: VendorWithTbeFields) => void // 수정된 시그니처
+
+}
+
+/**
+ * tanstack table 컬럼 정의 (중첩 헤더 버전)
+ */
+export function getColumns({
+ setRowAction,
+ openCommentSheet,
+ openFilesDialog,
+ openVendorContactsDialog
+}: GetColumnsProps): ColumnDef<VendorWithTbeFields>[] {
+ // ----------------------------------------------------------------
+ // 1) Select 컬럼 (체크박스)
+ // ----------------------------------------------------------------
+ const selectColumn: ColumnDef<VendorWithTbeFields> = {
+ id: "select",
+ header: ({ table }) => (
+ <Checkbox
+ checked={
+ table.getIsAllPageRowsSelected() ||
+ (table.getIsSomePageRowsSelected() && "indeterminate")
+ }
+ onCheckedChange={(value) => table.toggleAllPageRowsSelected(!!value)}
+ aria-label="Select all"
+ className="translate-y-0.5"
+ />
+ ),
+ cell: ({ row }) => (
+ <Checkbox
+ checked={row.getIsSelected()}
+ onCheckedChange={(value) => row.toggleSelected(!!value)}
+ aria-label="Select row"
+ className="translate-y-0.5"
+ />
+ ),
+ size: 40,
+ enableSorting: false,
+ enableHiding: false,
+ }
+
+ // ----------------------------------------------------------------
+ // 2) 그룹화(Nested) 컬럼 구성
+ // ----------------------------------------------------------------
+ const groupMap: Record<string, ColumnDef<VendorWithTbeFields>[]> = {}
+
+ vendorTbeColumnsConfig.forEach((cfg) => {
+ const groupName = cfg.group || "_noGroup"
+ if (!groupMap[groupName]) {
+ groupMap[groupName] = []
+ }
+
+ // childCol: ColumnDef<VendorWithTbeFields>
+ const childCol: ColumnDef<VendorWithTbeFields> = {
+ accessorKey: cfg.id,
+ enableResizing: true,
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title={cfg.label} />
+ ),
+ meta: {
+ excelHeader: cfg.excelHeader,
+ group: cfg.group,
+ type: cfg.type,
+ },
+ // 셀 렌더링
+ cell: ({ row, getValue }) => {
+ // 1) 필드값 가져오기
+ const val = getValue()
+
+ if (cfg.id === "vendorName") {
+ const vendor = row.original;
+ const vendorId = vendor.vendorId;
+
+ // 협력업체 이름을 클릭할 수 있는 버튼으로 렌더링
+ const handleVendorNameClick = () => {
+ if (vendorId) {
+ openVendorContactsDialog(vendorId, vendor); // vendor 전체 객체 전달
+ } else {
+ toast.error("협력업체 ID를 찾을 수 없습니다.");
+ }
+ };
+
+ return (
+ <Button
+ variant="link"
+ className="p-0 h-auto text-left font-normal justify-start hover:underline"
+ onClick={handleVendorNameClick}
+ >
+ {val as string}
+ </Button>
+ );
+ }
+
+ if (cfg.id === "tbeResult") {
+ const vendor = row.original;
+ const tbeResult = vendor.tbeResult;
+ const filesCount = vendor.files?.length ?? 0;
+
+ // Only show button or link if there are files
+ if (filesCount > 0) {
+ // Function to handle clicking on the result
+ const handleTbeResultClick = () => {
+ setRowAction({ row, type: "tbeResult" });
+ };
+
+ if (!tbeResult) {
+ // No result yet, but files exist - show "결과 입력" button
+ return (
+ <Button
+ variant="outline"
+ size="sm"
+ onClick={handleTbeResultClick}
+ >
+ 결과 입력
+ </Button>
+ );
+ } else {
+ // Result exists - show as a hyperlink
+ let badgeVariant: "default" | "outline" | "destructive" | "secondary" = "outline";
+
+ // Set badge variant based on result
+ if (tbeResult === "pass") {
+ badgeVariant = "default";
+ } else if (tbeResult === "non-pass") {
+ badgeVariant = "destructive";
+ } else if (tbeResult === "conditional pass") {
+ badgeVariant = "secondary";
+ }
+
+ return (
+ <Button
+ variant="link"
+ className="p-0 h-auto underline"
+ onClick={handleTbeResultClick}
+ >
+ <Badge variant={badgeVariant}>
+ {tbeResult}
+ </Badge>
+ </Button>
+ );
+ }
+ }
+
+ // No files available, return empty cell
+ return null;
+ }
+
+
+ if (cfg.id === "vendorStatus") {
+ const statusVal = row.original.vendorStatus
+ if (!statusVal) return null
+ // const Icon = getStatusIcon(statusVal)
+ return (
+ <Badge variant="outline">
+ {statusVal}
+ </Badge>
+ )
+ }
+
+
+ if (cfg.id === "rfqVendorStatus") {
+ const statusVal = row.original.rfqVendorStatus
+ if (!statusVal) return null
+ // const Icon = getStatusIcon(statusVal)
+ const variant = statusVal ==="INVITED"?"default" :statusVal ==="DECLINED"?"destructive":statusVal ==="ACCEPTED"?"secondary":"outline"
+ return (
+ <Badge variant={variant}>
+ {statusVal}
+ </Badge>
+ )
+ }
+
+
+
+ // 예) TBE Updated (날짜)
+ if (cfg.id === "tbeUpdated") {
+ const dateVal = val as Date | undefined
+ if (!dateVal) return null
+ return formatDate(dateVal)
+ }
+
+ // 그 외 필드는 기본 값 표시
+ return val ?? ""
+ },
+ }
+
+ groupMap[groupName].push(childCol)
+ })
+
+ // groupMap → nestedColumns
+ const nestedColumns: ColumnDef<VendorWithTbeFields>[] = []
+ Object.entries(groupMap).forEach(([groupName, colDefs]) => {
+ if (groupName === "_noGroup") {
+ nestedColumns.push(...colDefs)
+ } else {
+ nestedColumns.push({
+ id: groupName,
+ header: groupName,
+ columns: colDefs,
+ })
+ }
+ })
+
+// ----------------------------------------------------------------
+// 3) Comments 컬럼
+// ----------------------------------------------------------------
+const commentsColumn: ColumnDef<VendorWithTbeFields> = {
+ id: "comments",
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="Comments" />
+ ),
+ cell: ({ row }) => {
+ const vendor = row.original
+ const commCount = vendor.comments?.length ?? 0
+
+ function handleClick() {
+ // rowAction + openCommentSheet
+ setRowAction({ row, type: "comments" })
+ openCommentSheet(vendor.tbeId ?? 0)
+ }
+
+ return (
+ <Button
+ variant="ghost"
+ size="sm"
+ className="relative h-8 w-8 p-0 group"
+ onClick={handleClick}
+ aria-label={
+ commCount > 0 ? `View ${commCount} comments` : "No comments"
+ }
+ >
+ <MessageSquare className="h-4 w-4 text-muted-foreground group-hover:text-primary transition-colors" />
+ {commCount > 0 && (
+ <Badge
+ variant="secondary"
+ className="pointer-events-none absolute -top-1 -right-1 h-4 min-w-[1rem] p-0 text-[0.625rem] leading-none flex items-center justify-center"
+ >
+ {commCount}
+ </Badge>
+ )}
+ <span className="sr-only">
+ {commCount > 0 ? `${commCount} Comments` : "No Comments"}
+ </span>
+ </Button>
+ )
+ },
+ enableSorting: false,
+ maxSize:80
+}
+
+ // ----------------------------------------------------------------
+ // 2) 액션컬럼 (빈 상태로 유지하여 에러 방지)
+ // ----------------------------------------------------------------
+ const actionsColumn: ColumnDef<VendorWithTbeFields> = {
+ id: "actions",
+ enableHiding: false,
+ cell: () => {
+ // 빈 셀 반환 (액션 없음)
+ return null
+ },
+ size: 40,
+ enableSorting: false,
+ }
+// ----------------------------------------------------------------
+// 3) Files Column - Add before Comments column
+// ----------------------------------------------------------------
+const filesColumn: ColumnDef<VendorWithTbeFields> = {
+ id: "files",
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="Response Files" />
+ ),
+ cell: ({ row }) => {
+ const vendor = row.original
+ // We'll assume that files count will be populated from the backend
+ // You'll need to modify your getTBE function to include files
+ const filesCount = vendor.files?.length ?? 0
+
+ function handleClick() {
+ // Open files dialog
+ setRowAction({ row, type: "files" })
+ openFilesDialog(vendor.tbeId ?? 0, vendor.vendorId ?? 0)
+ }
+
+ return (
+ <div className="flex items-center justify-center">
+<Button
+ variant="ghost"
+ size="sm"
+ className="relative h-8 w-8 p-0 group"
+ onClick={handleClick}
+ aria-label={filesCount > 0 ? `View ${filesCount} files` : "Upload file"}
+>
+ {/* 아이콘: 중앙 정렬을 위해 Button 자체가 flex container */}
+ <Download className="h-4 w-4 text-muted-foreground group-hover:text-primary transition-colors" />
+
+ {/* 파일 개수가 1개 이상이면 뱃지 표시 */}
+ {filesCount > 0 && (
+ <Badge
+ variant="secondary"
+ className="pointer-events-none absolute -top-1 -right-1 h-4 min-w-[1rem] p-0 text-[0.625rem] leading-none flex items-center justify-center"
+ >
+ {filesCount}
+ </Badge>
+ )}
+
+ <span className="sr-only">
+ {filesCount > 0 ? `${filesCount} Files` : "Upload File"}
+ </span>
+</Button>
+ </div>
+ )
+ },
+ enableSorting: false,
+ maxSize: 80
+}
+
+// ----------------------------------------------------------------
+// 5) 최종 컬럼 배열 - Update to include the files column
+// ----------------------------------------------------------------
+return [
+ selectColumn,
+ ...nestedColumns,
+ filesColumn, // Add the files column before comments
+ commentsColumn,
+ actionsColumn,
+]
+
+} \ No newline at end of file
diff --git a/lib/rfqs-tech/tbe-table/tbe-table-toolbar-actions.tsx b/lib/rfqs-tech/tbe-table/tbe-table-toolbar-actions.tsx
new file mode 100644
index 00000000..f78e539c
--- /dev/null
+++ b/lib/rfqs-tech/tbe-table/tbe-table-toolbar-actions.tsx
@@ -0,0 +1,67 @@
+"use client"
+
+import * as React from "react"
+import { type Table } from "@tanstack/react-table"
+import { Download, Upload } from "lucide-react"
+import { toast } from "sonner"
+
+import { exportTableToExcel } from "@/lib/export"
+import { Button } from "@/components/ui/button"
+
+
+import { InviteVendorsDialog } from "./invite-vendors-dialog"
+import { VendorWithTbeFields } from "@/config/vendorTbeColumnsConfig"
+
+interface VendorsTableToolbarActionsProps {
+ table: Table<VendorWithTbeFields>
+ rfqId: number
+}
+
+export function VendorsTableToolbarActions({ table,rfqId }: VendorsTableToolbarActionsProps) {
+ // 파일 input을 숨기고, 버튼 클릭 시 참조해 클릭하는 방식
+ const fileInputRef = React.useRef<HTMLInputElement>(null)
+
+ // 파일이 선택되었을 때 처리
+
+ function handleImportClick() {
+ // 숨겨진 <input type="file" /> 요소를 클릭
+ fileInputRef.current?.click()
+ }
+
+ const invitationPossibeVendors = React.useMemo(() => {
+ return table
+ .getFilteredSelectedRowModel()
+ .rows
+ .map(row => row.original)
+ .filter(vendor => vendor.technicalResponseStatus === null);
+ }, [table.getFilteredSelectedRowModel().rows]);
+
+ return (
+ <div className="flex items-center gap-2">
+ {invitationPossibeVendors.length > 0 &&
+ (
+ <InviteVendorsDialog
+ vendors={invitationPossibeVendors}
+ rfqId = {rfqId}
+ onSuccess={() => table.toggleAllRowsSelected(false)}
+ />
+ )
+ }
+
+ <Button
+ variant="outline"
+ size="sm"
+ onClick={() =>
+ exportTableToExcel(table, {
+ filename: "tasks",
+ excludeColumns: ["select"],
+ })
+ }
+ className="gap-2"
+ >
+ <Download className="size-4" aria-hidden="true" />
+ <span className="hidden sm:inline">Export</span>
+ </Button>
+ </div>
+ )
+} \ No newline at end of file
diff --git a/lib/rfqs-tech/tbe-table/tbe-table.tsx b/lib/rfqs-tech/tbe-table/tbe-table.tsx
new file mode 100644
index 00000000..a162edbb
--- /dev/null
+++ b/lib/rfqs-tech/tbe-table/tbe-table.tsx
@@ -0,0 +1,243 @@
+"use client"
+
+import * as React from "react"
+import { useRouter } from "next/navigation"
+import type {
+ DataTableAdvancedFilterField,
+ DataTableFilterField,
+ DataTableRowAction,
+} from "@/types/table"
+
+import { toSentenceCase } from "@/lib/utils"
+import { useDataTable } from "@/hooks/use-data-table"
+import { DataTable } from "@/components/data-table/data-table"
+import { DataTableAdvancedToolbar } from "@/components/data-table/data-table-advanced-toolbar"
+import { getColumns } from "./tbe-table-columns"
+import { vendors } from "@/db/schema/vendors"
+import { VendorsTableToolbarActions } from "./tbe-table-toolbar-actions"
+import { fetchRfqAttachmentsbyCommentId, getTBE } from "../service"
+import { InviteVendorsDialog } from "./invite-vendors-dialog"
+import { CommentSheet, TbeComment } from "./comments-sheet"
+import { VendorWithTbeFields } from "@/config/vendorTbeColumnsConfig"
+import { TBEFileDialog } from "./file-dialog"
+import { TbeResultDialog } from "./tbe-result-dialog"
+import { VendorContactsDialog } from "./vendor-contact-dialog"
+import { useSession } from "next-auth/react" // Next-auth session hook 추가
+
+interface VendorsTableProps {
+ promises: Promise<
+ [
+ Awaited<ReturnType<typeof getTBE>>,
+ ]
+ >
+ rfqId: number
+}
+
+
+export function TbeTable({ promises, rfqId }: VendorsTableProps) {
+ // Suspense로 받아온 데이터
+ const [{ data: rawData, pageCount }] = React.use(promises)
+
+ // 벤더별로 데이터 그룹화
+ const data = React.useMemo(() => {
+ const vendorMap = new Map<number, VendorWithTbeFields>()
+
+ rawData.forEach((item) => {
+ const vendorId = item.vendorId
+
+ if (vendorMap.has(vendorId)) {
+ // 기존 벤더 데이터가 있으면 파일과 댓글을 합침
+ const existing = vendorMap.get(vendorId)!
+
+ // 파일 합치기 (중복 제거)
+ const existingFileIds = new Set(existing.files.map(f => f.id))
+ const newFiles = item.files.filter(f => !existingFileIds.has(f.id))
+ existing.files = [...existing.files, ...newFiles]
+
+ // 댓글 합치기 (중복 제거)
+ const existingCommentIds = new Set(existing.comments.map(c => c.id))
+ const newComments = item.comments.filter(c => !existingCommentIds.has(c.id))
+ existing.comments = [...existing.comments, ...newComments]
+
+ } else {
+ // 새로운 벤더 데이터 추가
+ vendorMap.set(vendorId, { ...item, vendorResponseId: item.id })
+ }
+ })
+
+ return Array.from(vendorMap.values())
+ }, [rawData])
+
+ const { data: session } = useSession() // 세션 정보 가져오기
+
+ const currentUserId = session?.user?.id ? parseInt(session.user.id, 10) : 0
+
+
+ const [rowAction, setRowAction] = React.useState<DataTableRowAction<VendorWithTbeFields> | null>(null)
+
+ // **router** 획득
+ const router = useRouter()
+
+ const [initialComments, setInitialComments] = React.useState<TbeComment[]>([])
+ const [commentSheetOpen, setCommentSheetOpen] = React.useState(false)
+
+ const [isFileDialogOpen, setIsFileDialogOpen] = React.useState(false)
+ const [selectedVendorId, setSelectedVendorId] = React.useState<number | null>(null)
+ const [selectedTbeId, setSelectedTbeId] = React.useState<number | null>(null)
+ const [isContactDialogOpen, setIsContactDialogOpen] = React.useState(false)
+ const [selectedVendor, setSelectedVendor] = React.useState<VendorWithTbeFields | null>(null)
+
+ // Add handleRefresh function
+ const handleRefresh = React.useCallback(() => {
+ router.refresh();
+ }, [router]);
+
+ React.useEffect(() => {
+ if (rowAction?.type === "comments") {
+ // rowAction가 새로 세팅된 뒤 여기서 openCommentSheet 실행
+ openCommentSheet()
+ } else if (rowAction?.type === "files") {
+ // Handle files action
+ const vendorId = rowAction.row.original.vendorId;
+ const tbeId = rowAction.row.original.tbeId ?? 0;
+ openFilesDialog(tbeId, vendorId);
+ }
+ }, [rowAction])
+
+ async function openCommentSheet() {
+ setInitialComments([])
+
+ const comments = rowAction?.row.original.comments
+ const vendorId = rowAction?.row.original.vendorId
+ const tbeId = rowAction?.row.original.tbeId
+ if (comments && comments.length > 0) {
+ const commentWithAttachments: TbeComment[] = await Promise.all(
+ comments.map(async (c) => {
+ const attachments = await fetchRfqAttachmentsbyCommentId(c.id)
+
+ return {
+ ...c,
+ commentedBy: currentUserId, // DB나 API 응답에 있다고 가정
+ attachments,
+ }
+ })
+ )
+ // 3) state에 저장 -> CommentSheet에서 initialComments로 사용
+ setInitialComments(commentWithAttachments)
+ }
+ setSelectedTbeId(tbeId ?? 0)
+ setSelectedVendorId(vendorId ?? 0)
+ setCommentSheetOpen(true)
+ }
+
+ const openFilesDialog = (tbeId: number, vendorId: number) => {
+ setSelectedTbeId(tbeId)
+ setSelectedVendorId(vendorId)
+ setIsFileDialogOpen(true)
+ }
+ const openVendorContactsDialog = (vendorId: number, vendor: VendorWithTbeFields) => {
+ setSelectedVendorId(vendorId)
+ setSelectedVendor(vendor)
+ setIsContactDialogOpen(true)
+ }
+
+ // getColumns() 호출 시, router를 주입
+ const columns = React.useMemo(
+ () => getColumns({ setRowAction, router, openCommentSheet, openFilesDialog, openVendorContactsDialog }),
+ [setRowAction, router]
+ )
+
+ const filterFields: DataTableFilterField<VendorWithTbeFields>[] = [
+ ]
+
+ const advancedFilterFields: DataTableAdvancedFilterField<VendorWithTbeFields>[] = [
+ { id: "vendorName", label: "Vendor Name", type: "text" },
+ { id: "vendorCode", label: "Vendor Code", type: "text" },
+ { id: "email", label: "Email", type: "text" },
+ { id: "country", label: "Country", type: "text" },
+ {
+ id: "vendorStatus",
+ label: "Vendor Status",
+ type: "multi-select",
+ options: vendors.status.enumValues.map((status) => ({
+ label: toSentenceCase(status),
+ value: status,
+ })),
+ },
+ { id: "rfqVendorUpdated", label: "Updated at", type: "date" },
+ ]
+
+
+ const { table } = useDataTable({
+ data,
+ columns,
+ pageCount,
+ filterFields,
+ enablePinning: true,
+ enableAdvancedFilter: true,
+ initialState: {
+ sorting: [{ id: "rfqVendorUpdated", desc: true }],
+ columnPinning: { right: ["comments"] },
+ },
+ getRowId: (originalRow) => String(originalRow.id),
+ shallow: false,
+ clearOnDefault: true,
+ })
+
+
+
+ return (
+ <div style={{ maxWidth: '80vw' }}>
+ <DataTable
+ table={table}
+ >
+ <DataTableAdvancedToolbar
+ table={table}
+ filterFields={advancedFilterFields}
+ shallow={false}
+ >
+ <VendorsTableToolbarActions table={table} rfqId={rfqId} />
+ </DataTableAdvancedToolbar>
+ </DataTable>
+ <InviteVendorsDialog
+ vendors={rowAction?.row.original ? [rowAction?.row.original] : []}
+ onOpenChange={() => setRowAction(null)}
+ rfqId={rfqId}
+ open={rowAction?.type === "invite"}
+ showTrigger={false}
+ />
+ <CommentSheet
+ currentUserId={currentUserId}
+ open={commentSheetOpen}
+ onOpenChange={setCommentSheetOpen}
+ rfqId={rfqId}
+ tbeId={selectedTbeId ?? 0}
+ vendorId={selectedVendorId ?? 0}
+ initialComments={initialComments}
+ />
+
+ <TBEFileDialog
+ isOpen={isFileDialogOpen}
+ onOpenChange={setIsFileDialogOpen}
+ tbeId={selectedTbeId ?? 0}
+ vendorId={selectedVendorId ?? 0}
+ rfqId={rfqId} // Use the prop directly instead of data[0]?.rfqId
+ onRefresh={handleRefresh}
+ />
+
+ <TbeResultDialog
+ open={rowAction?.type === "tbeResult"}
+ onOpenChange={() => setRowAction(null)}
+ tbe={rowAction?.row.original ?? null}
+ />
+
+ <VendorContactsDialog
+ isOpen={isContactDialogOpen}
+ onOpenChange={setIsContactDialogOpen}
+ vendorId={selectedVendorId}
+ vendor={selectedVendor}
+ />
+
+ </div>
+ )
+} \ No newline at end of file
diff --git a/lib/rfqs-tech/tbe-table/vendor-contact-dialog.tsx b/lib/rfqs-tech/tbe-table/vendor-contact-dialog.tsx
new file mode 100644
index 00000000..3619fe77
--- /dev/null
+++ b/lib/rfqs-tech/tbe-table/vendor-contact-dialog.tsx
@@ -0,0 +1,71 @@
+"use client"
+
+import * as React from "react"
+import {
+ Dialog,
+ DialogContent,
+ DialogHeader,
+ DialogTitle,
+} from "@/components/ui/dialog"
+import { VendorContactsTable } from "./vendor-contact/vendor-contact-table"
+import { Badge } from "@/components/ui/badge"
+import { VendorWithTbeFields } from "@/config/vendorTbeColumnsConfig"
+
+interface VendorContactsDialogProps {
+ isOpen: boolean
+ onOpenChange: (open: boolean) => void
+ vendorId: number | null
+ vendor: VendorWithTbeFields | null
+}
+
+export function VendorContactsDialog({
+ isOpen,
+ onOpenChange,
+ vendorId,
+ vendor,
+}: VendorContactsDialogProps) {
+ return (
+ <Dialog open={isOpen} onOpenChange={onOpenChange}>
+ <DialogContent className="max-w-[90wv] sm:max-h-[80vh] overflow-auto" style={{maxWidth:1000, height:480}}>
+ <DialogHeader>
+ <div className="flex flex-col space-y-2">
+ <DialogTitle>협력업체 연락처</DialogTitle>
+ {vendor && (
+ <div className="flex flex-col space-y-1 mt-2">
+ <div className="text-sm text-muted-foreground">
+ <span className="font-medium text-foreground">{vendor.vendorName}</span>
+ {vendor.vendorCode && (
+ <span className="ml-2 text-xs text-muted-foreground">({vendor.vendorCode})</span>
+ )}
+ </div>
+ <div className="flex items-center">
+ {vendor.vendorStatus && (
+ <Badge variant="outline" className="mr-2">
+ {vendor.vendorStatus}
+ </Badge>
+ )}
+ {vendor.rfqVendorStatus && (
+ <Badge
+ variant={
+ vendor.rfqVendorStatus === "INVITED" ? "default" :
+ vendor.rfqVendorStatus === "DECLINED" ? "destructive" :
+ vendor.rfqVendorStatus === "ACCEPTED" ? "secondary" : "outline"
+ }
+ >
+ {vendor.rfqVendorStatus}
+ </Badge>
+ )}
+ </div>
+ </div>
+ )}
+ </div>
+ </DialogHeader>
+ {vendorId && (
+ <div className="py-4">
+ <VendorContactsTable vendorId={vendorId} />
+ </div>
+ )}
+ </DialogContent>
+ </Dialog>
+ )
+} \ No newline at end of file
diff --git a/lib/rfqs-tech/tbe-table/vendor-contact/vendor-contact-table-column.tsx b/lib/rfqs-tech/tbe-table/vendor-contact/vendor-contact-table-column.tsx
new file mode 100644
index 00000000..fcd0c3fb
--- /dev/null
+++ b/lib/rfqs-tech/tbe-table/vendor-contact/vendor-contact-table-column.tsx
@@ -0,0 +1,70 @@
+"use client"
+// Because columns rely on React state/hooks for row actions
+
+import * as React from "react"
+import { ColumnDef, Row } from "@tanstack/react-table"
+import { ClientDataTableColumnHeaderSimple } from "@/components/client-data-table/data-table-column-simple-header"
+import { formatDate } from "@/lib/utils"
+import { Checkbox } from "@/components/ui/checkbox"
+import { VendorData } from "./vendor-contact-table"
+
+
+/** getColumns: return array of ColumnDef for 'vendors' data */
+export function getColumns(): ColumnDef<VendorData>[] {
+ return [
+
+ // Vendor Name
+ {
+ accessorKey: "contactName",
+ header: ({ column }) => (
+ <ClientDataTableColumnHeaderSimple column={column} title="Contact Name" />
+ ),
+ cell: ({ row }) => row.getValue("contactName"),
+ },
+
+ // Vendor Code
+ {
+ accessorKey: "contactPosition",
+ header: ({ column }) => (
+ <ClientDataTableColumnHeaderSimple column={column} title="Position" />
+ ),
+ cell: ({ row }) => row.getValue("contactPosition"),
+ },
+
+ // Status
+ {
+ accessorKey: "contactEmail",
+ header: ({ column }) => (
+ <ClientDataTableColumnHeaderSimple column={column} title="Email" />
+ ),
+ cell: ({ row }) => row.getValue("contactEmail"),
+ },
+
+ // Country
+ {
+ accessorKey: "contactPhone",
+ header: ({ column }) => (
+ <ClientDataTableColumnHeaderSimple column={column} title="Phone" />
+ ),
+ cell: ({ row }) => row.getValue("contactPhone"),
+ },
+
+ // Created At
+ {
+ accessorKey: "createdAt",
+ header: ({ column }) => (
+ <ClientDataTableColumnHeaderSimple column={column} title="Created At" />
+ ),
+ cell: ({ cell }) => formatDate(cell.getValue() as Date),
+ },
+
+ // Updated At
+ {
+ accessorKey: "updatedAt",
+ header: ({ column }) => (
+ <ClientDataTableColumnHeaderSimple column={column} title="Updated At" />
+ ),
+ cell: ({ cell }) => formatDate(cell.getValue() as Date),
+ },
+ ]
+} \ No newline at end of file
diff --git a/lib/rfqs-tech/tbe-table/vendor-contact/vendor-contact-table.tsx b/lib/rfqs-tech/tbe-table/vendor-contact/vendor-contact-table.tsx
new file mode 100644
index 00000000..c079da02
--- /dev/null
+++ b/lib/rfqs-tech/tbe-table/vendor-contact/vendor-contact-table.tsx
@@ -0,0 +1,89 @@
+'use client'
+
+import * as React from "react"
+import { ClientDataTable } from "@/components/client-data-table/data-table"
+import { getColumns } from "./vendor-contact-table-column"
+import { DataTableAdvancedFilterField } from "@/types/table"
+import { Loader2 } from "lucide-react"
+import { useToast } from "@/hooks/use-toast"
+import { getVendorContactsByVendorId } from "../../service"
+
+export interface VendorData {
+ id: number
+ contactName: string
+ contactPosition: string | null
+ contactEmail: string
+ contactPhone: string | null
+ isPrimary: boolean | null
+ createdAt: Date
+ updatedAt: Date
+}
+
+interface VendorContactsTableProps {
+ vendorId: number
+}
+
+export function VendorContactsTable({ vendorId }: VendorContactsTableProps) {
+ const { toast } = useToast()
+
+ const columns = React.useMemo(
+ () => getColumns(),
+ []
+ )
+
+ const [vendorContacts, setVendorContacts] = React.useState<VendorData[]>([])
+ const [isLoading, setIsLoading] = React.useState(false)
+
+ React.useEffect(() => {
+ async function loadVendorContacts() {
+ setIsLoading(true)
+ try {
+ const result = await getVendorContactsByVendorId(vendorId)
+ if (result.success && result.data) {
+ // undefined 체크 추가 및 타입 캐스팅
+ setVendorContacts(result.data as VendorData[])
+ } else {
+ throw new Error(result.error || "Unknown error occurred")
+ }
+ } catch (error) {
+ console.error("협력업체 연락처 로드 오류:", error)
+ toast({
+ title: "Error",
+ description: "Failed to load vendor contacts",
+ variant: "destructive",
+ })
+ } finally {
+ setIsLoading(false)
+ }
+ }
+ loadVendorContacts()
+ }, [toast, vendorId])
+
+ const advancedFilterFields: DataTableAdvancedFilterField<VendorData>[] = [
+ { id: "contactName", label: "Contact Name", type: "text" },
+ { id: "contactPosition", label: "Posiotion", type: "text" },
+ { id: "contactEmail", label: "Email", type: "text" },
+ { id: "contactPhone", label: "Phone", type: "text" },
+
+
+ ]
+
+ // If loading, show a flex container that fills the parent and centers the spinner
+ if (isLoading) {
+ return (
+ <div className="flex h-full w-full items-center justify-center">
+ <Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
+ </div>
+ )
+ }
+
+ // Otherwise, show the table
+ return (
+ <ClientDataTable
+ data={vendorContacts}
+ columns={columns}
+ advancedFilterFields={advancedFilterFields}
+ >
+ </ClientDataTable>
+ )
+} \ No newline at end of file