summaryrefslogtreecommitdiff
path: root/lib/rfqs-ship/vendor-table
diff options
context:
space:
mode:
authordujinkim <dujin.kim@dtsolution.co.kr>2025-05-13 03:12:10 +0000
committerdujinkim <dujin.kim@dtsolution.co.kr>2025-05-13 03:12:10 +0000
commit1c790b57447aff36820437d6f18a969de9b45baa (patch)
tree5c8d58c70d1000363b252c98f037cea77a8511a4 /lib/rfqs-ship/vendor-table
parent6480a7fd21313417e37494698d69d62a62428860 (diff)
(대표님) lib/rfq-ships
Diffstat (limited to 'lib/rfqs-ship/vendor-table')
-rw-r--r--lib/rfqs-ship/vendor-table/add-vendor-dialog.tsx37
-rw-r--r--lib/rfqs-ship/vendor-table/comments-sheet.tsx318
-rw-r--r--lib/rfqs-ship/vendor-table/feature-flags-provider.tsx108
-rw-r--r--lib/rfqs-ship/vendor-table/invite-vendors-dialog.tsx497
-rw-r--r--lib/rfqs-ship/vendor-table/vendor-list/vendor-list-table-column.tsx154
-rw-r--r--lib/rfqs-ship/vendor-table/vendor-list/vendor-list-table.tsx142
-rw-r--r--lib/rfqs-ship/vendor-table/vendors-table-columns.tsx255
-rw-r--r--lib/rfqs-ship/vendor-table/vendors-table-floating-bar.tsx112
-rw-r--r--lib/rfqs-ship/vendor-table/vendors-table-toolbar-actions.tsx62
-rw-r--r--lib/rfqs-ship/vendor-table/vendors-table.tsx225
10 files changed, 1910 insertions, 0 deletions
diff --git a/lib/rfqs-ship/vendor-table/add-vendor-dialog.tsx b/lib/rfqs-ship/vendor-table/add-vendor-dialog.tsx
new file mode 100644
index 00000000..8ec5b9f4
--- /dev/null
+++ b/lib/rfqs-ship/vendor-table/add-vendor-dialog.tsx
@@ -0,0 +1,37 @@
+"use client"
+
+import * as React from "react"
+import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog"
+import { Button } from "@/components/ui/button"
+import { VendorsListTable } from "./vendor-list/vendor-list-table"
+
+interface VendorsListTableProps {
+ rfqId: number // so we know which RFQ to insert into
+ }
+
+
+/**
+ * A dialog that contains a client-side table or infinite scroll
+ * for "all vendors," allowing the user to select vendors and add them to the RFQ.
+ */
+export function AddVendorDialog({ rfqId }: VendorsListTableProps) {
+ const [open, setOpen] = React.useState(false)
+
+ return (
+ <Dialog open={open} onOpenChange={setOpen}>
+ <DialogTrigger asChild>
+ <Button size="sm">
+ Add Vendor
+ </Button>
+ </DialogTrigger>
+ <DialogContent className="max-w-[90wv] sm:max-h-[80vh] overflow-auto" style={{maxWidth:1600, height:680}}>
+ <DialogHeader>
+ <DialogTitle>Add Vendor to RFQ</DialogTitle>
+ </DialogHeader>
+
+ <VendorsListTable rfqId={rfqId}/>
+
+ </DialogContent>
+ </Dialog>
+ )
+} \ No newline at end of file
diff --git a/lib/rfqs-ship/vendor-table/comments-sheet.tsx b/lib/rfqs-ship/vendor-table/comments-sheet.tsx
new file mode 100644
index 00000000..441fdcf1
--- /dev/null
+++ b/lib/rfqs-ship/vendor-table/comments-sheet.tsx
@@ -0,0 +1,318 @@
+"use client"
+
+import * as React from "react"
+import { useForm, useFieldArray } from "react-hook-form"
+import { z } from "zod"
+import { zodResolver } from "@hookform/resolvers/zod"
+import { Download, X, Loader2 } from "lucide-react"
+import prettyBytes from "pretty-bytes"
+import { toast } from "sonner"
+
+import {
+ Sheet,
+ SheetClose,
+ SheetContent,
+ SheetDescription,
+ SheetFooter,
+ SheetHeader,
+ SheetTitle,
+} from "@/components/ui/sheet"
+import { Button } from "@/components/ui/button"
+import {
+ Form,
+ FormControl,
+ FormField,
+ FormItem,
+ FormLabel,
+ FormMessage,
+} from "@/components/ui/form"
+import { Textarea } from "@/components/ui/textarea"
+import {
+ Dropzone,
+ DropzoneZone,
+ DropzoneUploadIcon,
+ DropzoneTitle,
+ DropzoneDescription,
+ DropzoneInput,
+} from "@/components/ui/dropzone"
+import {
+ Table,
+ TableHeader,
+ TableRow,
+ TableHead,
+ TableBody,
+ TableCell,
+} from "@/components/ui/table"
+
+import { createRfqCommentWithAttachments } from "../service"
+import { formatDate } from "@/lib/utils"
+
+
+export interface MatchedVendorComment {
+ id: number
+ commentText: string
+ commentedBy?: number
+ commentedByEmail?: string
+ createdAt?: Date
+ attachments?: {
+ id: number
+ fileName: string
+ filePath: string
+ }[]
+}
+
+// 1) props 정의
+interface CommentSheetProps extends React.ComponentPropsWithRef<typeof Sheet> {
+ initialComments?: MatchedVendorComment[]
+ currentUserId: number
+ rfqId: number
+ vendorId: number
+ onCommentsUpdated?: (comments: MatchedVendorComment[]) => void
+ isLoading?: boolean // New prop
+}
+
+// 2) 폼 스키마
+const commentFormSchema = z.object({
+ commentText: z.string().min(1, "댓글을 입력하세요."),
+ newFiles: z.array(z.any()).optional(), // File[]
+})
+type CommentFormValues = z.infer<typeof commentFormSchema>
+
+const MAX_FILE_SIZE = 30e6 // 30MB
+
+export function CommentSheet({
+ rfqId,
+ vendorId,
+ initialComments = [],
+ currentUserId,
+ onCommentsUpdated,
+ isLoading = false, // Default to false
+ ...props
+}: CommentSheetProps) {
+
+ const [comments, setComments] = React.useState<MatchedVendorComment[]>(initialComments)
+ const [isPending, startTransition] = React.useTransition()
+
+ React.useEffect(() => {
+ setComments(initialComments)
+ }, [initialComments])
+
+ const form = useForm<CommentFormValues>({
+ resolver: zodResolver(commentFormSchema),
+ defaultValues: {
+ commentText: "",
+ newFiles: [],
+ },
+ })
+
+ const { fields: newFileFields, append, remove } = useFieldArray({
+ control: form.control,
+ name: "newFiles",
+ })
+
+ // (A) 기존 코멘트 렌더링
+ function renderExistingComments() {
+
+ if (isLoading) {
+ return (
+ <div className="flex justify-center items-center h-32">
+ <Loader2 className="h-4 w-4 animate-spin text-muted-foreground" />
+ <span className="ml-2 text-sm text-muted-foreground">Loading comments...</span>
+ </div>
+ )
+ }
+
+ if (comments.length === 0) {
+ return <p className="text-sm text-muted-foreground">No comments yet</p>
+ }
+ return (
+ <Table>
+ <TableHeader>
+ <TableRow>
+ <TableHead className="w-1/2">Comment</TableHead>
+ <TableHead>Attachments</TableHead>
+ <TableHead>Created At</TableHead>
+ <TableHead>Created By</TableHead>
+ </TableRow>
+ </TableHeader>
+ <TableBody>
+ {comments.map((c) => (
+ <TableRow key={c.id}>
+ <TableCell>{c.commentText}</TableCell>
+ <TableCell>
+ {!c.attachments?.length && (
+ <span className="text-sm text-muted-foreground">No files</span>
+ )}
+ {c.attachments?.length && (
+ <div className="flex flex-col gap-1">
+ {c.attachments.map((att) => (
+ <div key={att.id} className="flex items-center gap-2">
+ <a
+ href={`/api/rfq-download?path=${encodeURIComponent(att.filePath)}`}
+ download
+ target="_blank"
+ rel="noreferrer"
+ className="inline-flex items-center gap-1 text-blue-600 underline"
+ >
+ <Download className="h-4 w-4" />
+ {att.fileName}
+ </a>
+ </div>
+ ))}
+ </div>
+ )}
+ </TableCell>
+ <TableCell> {c.createdAt ? formatDate(c.createdAt) : "-"}</TableCell>
+ <TableCell>{c.commentedByEmail ?? "-"}</TableCell>
+ </TableRow>
+ ))}
+ </TableBody>
+ </Table>
+ )
+ }
+
+ // (B) 파일 드롭
+ function handleDropAccepted(files: File[]) {
+ append(files)
+ }
+
+ // (C) Submit
+ async function onSubmit(data: CommentFormValues) {
+ if (!rfqId) return
+ startTransition(async () => {
+ try {
+ const res = await createRfqCommentWithAttachments({
+ rfqId,
+ vendorId,
+ commentText: data.commentText,
+ commentedBy: currentUserId,
+ evaluationId: null,
+ cbeId: null,
+ files: data.newFiles,
+ })
+
+ if (!res.ok) {
+ throw new Error("Failed to create comment")
+ }
+
+ toast.success("Comment created")
+
+ // 임시로 새 코멘트 추가
+ const newComment: MatchedVendorComment = {
+ id: res.commentId, // 서버 응답
+ commentText: data.commentText,
+ commentedBy: currentUserId,
+ createdAt: res.createdAt,
+ attachments:
+ data.newFiles?.map((f) => ({
+ id: Math.floor(Math.random() * 1e6),
+ fileName: f.name,
+ filePath: "/uploads/" + f.name,
+ })) || [],
+ }
+ setComments((prev) => [...prev, newComment])
+ onCommentsUpdated?.([...comments, newComment])
+
+ form.reset()
+ } catch (err: any) {
+ console.error(err)
+ toast.error("Error: " + err.message)
+ }
+ })
+ }
+
+ return (
+ <Sheet {...props}>
+ <SheetContent className="flex flex-col gap-6 sm:max-w-lg">
+ <SheetHeader className="text-left">
+ <SheetTitle>Comments</SheetTitle>
+ <SheetDescription>
+ 필요시 첨부파일과 함께 문의/코멘트를 남길 수 있습니다.
+ </SheetDescription>
+ </SheetHeader>
+
+ <div className="max-h-[300px] overflow-y-auto">{renderExistingComments()}</div>
+
+ <Form {...form}>
+ <form onSubmit={form.handleSubmit(onSubmit)} className="flex flex-col gap-4">
+ <FormField
+ control={form.control}
+ name="commentText"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>New Comment</FormLabel>
+ <FormControl>
+ <Textarea placeholder="Enter your comment..." {...field} />
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+
+ <Dropzone
+ maxSize={MAX_FILE_SIZE}
+ onDropAccepted={handleDropAccepted}
+ onDropRejected={(rej) => {
+ toast.error("File rejected: " + (rej[0]?.file?.name || ""))
+ }}
+ >
+ {({ maxSize }) => (
+ <DropzoneZone className="flex justify-center">
+ <DropzoneInput />
+ <div className="flex items-center gap-6">
+ <DropzoneUploadIcon />
+ <div className="grid gap-0.5">
+ <DropzoneTitle>Drop to attach files</DropzoneTitle>
+ <DropzoneDescription>
+ Max size: {prettyBytes(maxSize || 0)}
+ </DropzoneDescription>
+ </div>
+ </div>
+ </DropzoneZone>
+ )}
+ </Dropzone>
+
+ {newFileFields.length > 0 && (
+ <div className="flex flex-col gap-2">
+ {newFileFields.map((field, idx) => {
+ const file = form.getValues(`newFiles.${idx}`)
+ if (!file) return null
+ return (
+ <div
+ key={field.id}
+ className="flex items-center justify-between border rounded p-2"
+ >
+ <span className="text-sm">
+ {file.name} ({prettyBytes(file.size)})
+ </span>
+ <Button
+ variant="ghost"
+ size="icon"
+ type="button"
+ onClick={() => remove(idx)}
+ >
+ <X className="h-4 w-4" />
+ </Button>
+ </div>
+ )
+ })}
+ </div>
+ )}
+
+ <SheetFooter className="gap-2 pt-4">
+ <SheetClose asChild>
+ <Button type="button" variant="outline">
+ Cancel
+ </Button>
+ </SheetClose>
+ <Button disabled={isPending}>
+ {isPending && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
+ Save
+ </Button>
+ </SheetFooter>
+ </form>
+ </Form>
+ </SheetContent>
+ </Sheet>
+ )
+} \ No newline at end of file
diff --git a/lib/rfqs-ship/vendor-table/feature-flags-provider.tsx b/lib/rfqs-ship/vendor-table/feature-flags-provider.tsx
new file mode 100644
index 00000000..81131894
--- /dev/null
+++ b/lib/rfqs-ship/vendor-table/feature-flags-provider.tsx
@@ -0,0 +1,108 @@
+"use client"
+
+import * as React from "react"
+import { useQueryState } from "nuqs"
+
+import { dataTableConfig, type DataTableConfig } from "@/config/data-table"
+import { cn } from "@/lib/utils"
+import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group"
+import {
+ Tooltip,
+ TooltipContent,
+ TooltipTrigger,
+} from "@/components/ui/tooltip"
+
+type FeatureFlagValue = DataTableConfig["featureFlags"][number]["value"]
+
+interface FeatureFlagsContextProps {
+ featureFlags: FeatureFlagValue[]
+ setFeatureFlags: (value: FeatureFlagValue[]) => void
+}
+
+const FeatureFlagsContext = React.createContext<FeatureFlagsContextProps>({
+ featureFlags: [],
+ setFeatureFlags: () => {},
+})
+
+export function useFeatureFlags() {
+ const context = React.useContext(FeatureFlagsContext)
+ if (!context) {
+ throw new Error(
+ "useFeatureFlags must be used within a FeatureFlagsProvider"
+ )
+ }
+ return context
+}
+
+interface FeatureFlagsProviderProps {
+ children: React.ReactNode
+}
+
+export function FeatureFlagsProvider({ children }: FeatureFlagsProviderProps) {
+ const [featureFlags, setFeatureFlags] = useQueryState<FeatureFlagValue[]>(
+ "flags",
+ {
+ defaultValue: [],
+ parse: (value) => value.split(",") as FeatureFlagValue[],
+ serialize: (value) => value.join(","),
+ eq: (a, b) =>
+ a.length === b.length && a.every((value, index) => value === b[index]),
+ clearOnDefault: true,
+ shallow: false,
+ }
+ )
+
+ return (
+ <FeatureFlagsContext.Provider
+ value={{
+ featureFlags,
+ setFeatureFlags: (value) => void setFeatureFlags(value),
+ }}
+ >
+ <div className="w-full overflow-x-auto">
+ <ToggleGroup
+ type="multiple"
+ variant="outline"
+ size="sm"
+ value={featureFlags}
+ onValueChange={(value: FeatureFlagValue[]) => setFeatureFlags(value)}
+ className="w-fit gap-0"
+ >
+ {dataTableConfig.featureFlags.map((flag, index) => (
+ <Tooltip key={flag.value}>
+ <ToggleGroupItem
+ value={flag.value}
+ className={cn(
+ "gap-2 whitespace-nowrap rounded-none px-3 text-xs data-[state=on]:bg-accent/70 data-[state=on]:hover:bg-accent/90",
+ {
+ "rounded-l-sm border-r-0": index === 0,
+ "rounded-r-sm":
+ index === dataTableConfig.featureFlags.length - 1,
+ }
+ )}
+ asChild
+ >
+ <TooltipTrigger>
+ <flag.icon className="size-3.5 shrink-0" aria-hidden="true" />
+ {flag.label}
+ </TooltipTrigger>
+ </ToggleGroupItem>
+ <TooltipContent
+ align="start"
+ side="bottom"
+ sideOffset={6}
+ className="flex max-w-60 flex-col space-y-1.5 border bg-background py-2 font-semibold text-foreground"
+ >
+ <div>{flag.tooltipTitle}</div>
+ <div className="text-xs text-muted-foreground">
+ {flag.tooltipDescription}
+ </div>
+ </TooltipContent>
+ </Tooltip>
+ ))}
+ </ToggleGroup>
+ </div>
+ {children}
+ </FeatureFlagsContext.Provider>
+ )
+}
diff --git a/lib/rfqs-ship/vendor-table/invite-vendors-dialog.tsx b/lib/rfqs-ship/vendor-table/invite-vendors-dialog.tsx
new file mode 100644
index 00000000..cdbfaa0f
--- /dev/null
+++ b/lib/rfqs-ship/vendor-table/invite-vendors-dialog.tsx
@@ -0,0 +1,497 @@
+"use client"
+
+import * as React from "react"
+import { useForm } from "react-hook-form"
+import { zodResolver } from "@hookform/resolvers/zod"
+import { type Row } from "@tanstack/react-table"
+import { Loader, Send, AlertTriangle, User } from "lucide-react"
+import { toast } from "sonner"
+import { z } from "zod"
+
+import { useMediaQuery } from "@/hooks/use-media-query"
+import { Button } from "@/components/ui/button"
+import {
+ Dialog,
+ DialogClose,
+ DialogContent,
+ DialogDescription,
+ DialogFooter,
+ DialogHeader,
+ DialogTitle,
+ DialogTrigger,
+} from "@/components/ui/dialog"
+import {
+ Drawer,
+ DrawerClose,
+ DrawerContent,
+ DrawerDescription,
+ DrawerFooter,
+ DrawerHeader,
+ DrawerTitle,
+ DrawerTrigger,
+} from "@/components/ui/drawer"
+import { Alert, AlertDescription } from "@/components/ui/alert"
+
+import { MatchedVendorRow } from "@/config/vendorRfbColumnsConfig"
+import { inviteVendors, createCbeEvaluation } from "../service"
+import { RfqType } from "@/lib/rfqs-ship/validations"
+import { Form, FormControl, FormDescription, FormField, FormItem, FormLabel, FormMessage } from "@/components/ui/form"
+import { Input } from "@/components/ui/input"
+import { Textarea } from "@/components/ui/textarea"
+import { ScrollArea } from "@/components/ui/scroll-area"
+import { Badge } from "@/components/ui/badge"
+import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"
+
+// CBE 폼 스키마 정의
+const formSchema = z.object({
+ paymentTerms: z.string().min(1, "결제 조건을 입력하세요"),
+ incoterms: z.string().min(1, "Incoterms를 입력하세요"),
+ deliverySchedule: z.string().min(1, "배송 일정을 입력하세요"),
+ notes: z.string().optional(),
+})
+
+type FormValues = z.infer<typeof formSchema>
+
+interface InviteVendorsDialogProps
+ extends React.ComponentPropsWithoutRef<typeof Dialog> {
+ vendors: Row<MatchedVendorRow>["original"][] | MatchedVendorRow[]
+ rfqId: number
+ rfqType: RfqType
+ showTrigger?: boolean
+ directCbe?: boolean
+ currentUser?: {
+ id: string
+ name?: string | null
+ email?: string | null
+ image?: string | null
+ companyId?: number | null
+ domain?: string | null
+ }
+ onSuccess?: () => void
+ children?: React.ReactNode
+}
+
+export function InviteVendorsDialog({
+ vendors,
+ rfqId,
+ rfqType,
+ showTrigger = true,
+ directCbe = false,
+ currentUser,
+ onSuccess,
+ children,
+ ...props
+}: InviteVendorsDialogProps) {
+ const [isInvitePending, startInviteTransition] = React.useTransition()
+ const isDesktop = useMediaQuery("(min-width: 640px)")
+ const [files, setFiles] = React.useState<FileList | null>(null)
+
+ // CBE 모드일 때 폼 상태 관리
+ const form = useForm<FormValues>({
+ resolver: zodResolver(formSchema),
+ defaultValues: {
+ paymentTerms: "",
+ incoterms: "",
+ deliverySchedule: "",
+ notes: "",
+ },
+ mode: "onChange",
+ })
+
+ // 폼 상태 감시
+ const { formState } = form
+ const isValid = formState.isValid &&
+ !!form.getValues("paymentTerms") &&
+ !!form.getValues("incoterms") &&
+ !!form.getValues("deliverySchedule")
+
+ // 기존 초대 함수
+ function onInvite() {
+ startInviteTransition(async () => {
+ const { error } = await inviteVendors({
+ rfqId,
+ vendorIds: vendors.map((vendor) => Number(vendor.id)),
+ rfqType
+ })
+
+ if (error) {
+ toast.error(error)
+ return
+ }
+
+ props.onOpenChange?.(false)
+ toast.success("Vendor invited")
+ onSuccess?.()
+ })
+ }
+
+ // CBE 요청 함수
+ async function onCbeRequest(data: FormValues) {
+ try {
+ startInviteTransition(async () => {
+ // 기본 FormData 생성
+ const formData = new FormData()
+
+ // rfqId 추가
+ formData.append("rfqId", String(rfqId))
+
+ // 폼 데이터 추가
+ Object.entries(data).forEach(([key, value]) => {
+ if (value !== undefined && value !== null) {
+ formData.append(key, String(value))
+ }
+ })
+
+ // 현재 사용자 ID 추가
+ if (currentUser?.id) {
+ formData.append("evaluatedBy", currentUser.id)
+ }
+
+ // 협력업체 ID만 추가
+ vendors.forEach((vendor) => {
+ formData.append("vendorIds[]", String(vendor.id))
+ })
+
+ // 파일 추가 (있는 경우에만)
+ if (files && files.length > 0) {
+ for (let i = 0; i < files.length; i++) {
+ formData.append("files", files[i])
+ }
+ }
+
+ // 서버 액션 호출
+ const response = await createCbeEvaluation(formData)
+
+ if (response.error) {
+ toast.error(response.error)
+ return
+ }
+
+ // 성공 처리
+ toast.success(`${vendors.length}개 협력업체에 CBE 요청이 성공적으로 전송되었습니다!`)
+ form.reset()
+ setFiles(null)
+ props.onOpenChange?.(false)
+ onSuccess?.()
+ })
+ } catch (error) {
+ console.error(error)
+ toast.error("CBE 요청 생성 중 오류가 발생했습니다.")
+ }
+ }
+
+ function handleDialogOpenChange(nextOpen: boolean) {
+ if (!nextOpen) {
+ form.reset()
+ setFiles(null)
+ }
+ props.onOpenChange?.(nextOpen)
+ }
+
+ // 필수 필드 라벨에 추가할 요소
+ const RequiredLabel = (
+ <span className="text-destructive ml-1 font-medium">*</span>
+ )
+
+ // CBE 모드일 때 폼 컨텐츠
+ const cbeFormContent = (
+ <Form {...form}>
+ <form onSubmit={form.handleSubmit(onCbeRequest)} className="space-y-4">
+ {/* 선택된 협력업체 정보 표시 */}
+ <div className="space-y-2">
+ <FormLabel>선택된 협력업체 ({vendors.length})</FormLabel>
+ <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>
+ <FormDescription>
+ 선택된 모든 협력업체의 등록된 연락처에게 CBE 요청이 전송됩니다.
+ </FormDescription>
+ </div>
+
+ {/* 작성자 정보 (읽기 전용) */}
+ {currentUser && (
+ <div className="border rounded-md p-3 space-y-2">
+ <FormLabel>작성자</FormLabel>
+ <div className="flex items-center gap-3">
+ {currentUser.image ? (
+ <Avatar className="h-8 w-8">
+ <AvatarImage src={currentUser.image} alt={currentUser.name || ""} />
+ <AvatarFallback>
+ {currentUser.name?.charAt(0) || <User className="h-4 w-4" />}
+ </AvatarFallback>
+ </Avatar>
+ ) : (
+ <Avatar className="h-8 w-8">
+ <AvatarFallback>
+ {currentUser.name?.charAt(0) || <User className="h-4 w-4" />}
+ </AvatarFallback>
+ </Avatar>
+ )}
+ <div>
+ <p className="text-sm font-medium">{currentUser.name || "Unknown User"}</p>
+ <p className="text-xs text-muted-foreground">{currentUser.email || ""}</p>
+ </div>
+ </div>
+ </div>
+ )}
+
+ {/* 결제 조건 - 필수 필드 */}
+ <FormField
+ control={form.control}
+ name="paymentTerms"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>
+ 결제 조건{RequiredLabel}
+ </FormLabel>
+ <FormControl>
+ <Input {...field} placeholder="예: Net 30" />
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+
+ {/* Incoterms - 필수 필드 */}
+ <FormField
+ control={form.control}
+ name="incoterms"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>
+ Incoterms{RequiredLabel}
+ </FormLabel>
+ <FormControl>
+ <Input {...field} placeholder="예: FOB, CIF" />
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+
+ {/* 배송 일정 - 필수 필드 */}
+ <FormField
+ control={form.control}
+ name="deliverySchedule"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>
+ 배송 일정{RequiredLabel}
+ </FormLabel>
+ <FormControl>
+ <Input {...field} placeholder="예: 계약 후 4주 이내" />
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+
+ {/* 추가 참고사항 - 선택 필드 */}
+ <FormField
+ control={form.control}
+ name="notes"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>추가 참고사항</FormLabel>
+ <FormControl>
+ <Textarea
+ {...field}
+ placeholder="필요한 경우 추가 정보를 입력하세요"
+ className="min-h-[100px]"
+ />
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+
+ {/* 파일 첨부 */}
+ <div className="space-y-2">
+ <FormLabel>첨부파일</FormLabel>
+ <Input
+ type="file"
+ multiple
+ onChange={(e) => setFiles(e.target.files)}
+ className="cursor-pointer"
+ />
+ <FormDescription>
+ CBE 요청에 첨부할 문서가 있다면 선택하세요.
+ </FormDescription>
+ </div>
+
+ <DialogFooter className="gap-2 sm:space-x-0 mt-6">
+ <DialogClose asChild>
+ <Button variant="outline">취소</Button>
+ </DialogClose>
+ <Button
+ type="submit"
+ variant="default"
+ disabled={!isValid || isInvitePending}
+ >
+ {isInvitePending && (
+ <Loader
+ className="mr-2 size-4 animate-spin"
+ aria-hidden="true"
+ />
+ )}
+ CBE 요청 발송하기
+ </Button>
+ </DialogFooter>
+ </form>
+ </Form>
+ )
+
+ // 기본 다이얼로그 컨텐츠
+ const defaultContent = (
+ <>
+ <DialogHeader>
+ <DialogTitle>벤더 초대 확인</DialogTitle>
+ <DialogDescription>
+ 선택한 <span className="font-medium">{vendors.length}</span>
+ {vendors.length === 1 ? "개 벤더" : "개 벤더"}에게 초대를 발송합니다.
+ </DialogDescription>
+ </DialogHeader>
+
+ {/* 편집 제한 경고 메시지 */}
+ <Alert variant="destructive" className="mt-4">
+ <AlertTriangle className="h-4 w-4" />
+ <AlertDescription className="font-medium">
+ 한 업체라도 초대를 하고 나면 아이템 편집과 RFQ 문서 첨부 편집은 불가능합니다.
+ </AlertDescription>
+ </Alert>
+
+ <DialogFooter className="gap-2 sm:space-x-0 mt-6">
+ <DialogClose asChild>
+ <Button variant="outline">취소</Button>
+ </DialogClose>
+ <Button
+ aria-label="Invite selected rows"
+ variant="destructive"
+ onClick={onInvite}
+ disabled={isInvitePending}
+ >
+ {isInvitePending && (
+ <Loader
+ className="mr-2 size-4 animate-spin"
+ aria-hidden="true"
+ />
+ )}
+ 초대하기
+ </Button>
+ </DialogFooter>
+ </>
+ )
+
+ if (isDesktop) {
+ return (
+ <Dialog {...props} onOpenChange={handleDialogOpenChange}>
+ {showTrigger && children ? (
+ <DialogTrigger asChild>
+ {children}
+ </DialogTrigger>
+ ) : showTrigger ? (
+ <DialogTrigger asChild>
+ <Button variant="outline" size="sm">
+ <Send className="mr-2 size-4" aria-hidden="true" />
+ {directCbe ? "CBE 요청" : "초대하기"} ({vendors.length})
+ </Button>
+ </DialogTrigger>
+ ) : null}
+ <DialogContent className={directCbe ? "sm:max-w-xl" : ""}>
+ {directCbe ? (
+ // CBE 직접 요청 모드
+ <>
+ <DialogHeader>
+ <DialogTitle>CBE 요청</DialogTitle>
+ <DialogDescription>
+ 선택한 협력업체({vendors.length}개)에게 CBE 요청을 발송합니다.
+ </DialogDescription>
+ </DialogHeader>
+ {cbeFormContent}
+ </>
+ ) : (
+ // 기존 초대 모드
+ defaultContent
+ )}
+ </DialogContent>
+ </Dialog>
+ )
+ }
+
+ return (
+ <Drawer {...props} onOpenChange={handleDialogOpenChange}>
+ {showTrigger && children ? (
+ <DrawerTrigger asChild>
+ {children}
+ </DrawerTrigger>
+ ) : showTrigger ? (
+ <DrawerTrigger asChild>
+ <Button variant="outline" size="sm">
+ <Send className="mr-2 size-4" aria-hidden="true" />
+ {directCbe ? "CBE 요청" : "초대하기"} ({vendors.length})
+ </Button>
+ </DrawerTrigger>
+ ) : null}
+ <DrawerContent>
+ {directCbe ? (
+ // CBE 직접 요청 모드 (모바일)
+ <>
+ <DrawerHeader>
+ <DrawerTitle>CBE 요청</DrawerTitle>
+ <DrawerDescription>
+ 선택한 협력업체({vendors.length}개)에게 CBE 요청을 발송합니다.
+ </DrawerDescription>
+ </DrawerHeader>
+ <div className="px-4 pb-4">
+ {cbeFormContent}
+ </div>
+ </>
+ ) : (
+ // 기존 초대 모드 (모바일)
+ <>
+ <DrawerHeader>
+ <DrawerTitle>벤더 초대 확인</DrawerTitle>
+ <DrawerDescription>
+ 선택한 <span className="font-medium">{vendors.length}</span>
+ {vendors.length === 1 ? "개 벤더" : "개 벤더"}에게 초대를 발송합니다.
+ </DrawerDescription>
+ </DrawerHeader>
+
+ {/* 편집 제한 경고 메시지 (모바일용) */}
+ <div className="px-4">
+ <Alert variant="destructive">
+ <AlertTriangle className="h-4 w-4" />
+ <AlertDescription className="font-medium">
+ 한 업체라도 초대를 하고 나면 아이템 편집과 RFQ 문서 첨부 편집은 불가능합니다.
+ </AlertDescription>
+ </Alert>
+ </div>
+
+ <DrawerFooter className="gap-2 sm:space-x-0 mt-4">
+ <DrawerClose asChild>
+ <Button variant="outline">취소</Button>
+ </DrawerClose>
+ <Button
+ aria-label="초대하기"
+ variant="destructive"
+ onClick={onInvite}
+ disabled={isInvitePending}
+ >
+ {isInvitePending && (
+ <Loader className="mr-2 size-4 animate-spin" aria-hidden="true" />
+ )}
+ 초대하기
+ </Button>
+ </DrawerFooter>
+ </>
+ )}
+ </DrawerContent>
+ </Drawer>
+ )
+} \ No newline at end of file
diff --git a/lib/rfqs-ship/vendor-table/vendor-list/vendor-list-table-column.tsx b/lib/rfqs-ship/vendor-table/vendor-list/vendor-list-table-column.tsx
new file mode 100644
index 00000000..bfcbe75b
--- /dev/null
+++ b/lib/rfqs-ship/vendor-table/vendor-list/vendor-list-table-column.tsx
@@ -0,0 +1,154 @@
+"use client"
+// Because columns rely on React state/hooks for row actions
+
+import * as React from "react"
+import { ColumnDef, Row } from "@tanstack/react-table"
+import { VendorData } from "./vendor-list-table"
+import { ClientDataTableColumnHeaderSimple } from "@/components/client-data-table/data-table-column-simple-header"
+import { formatDate } from "@/lib/utils"
+import { Checkbox } from "@/components/ui/checkbox"
+
+export interface DataTableRowAction<TData> {
+ row: Row<TData>
+ type: "open" | "update" | "delete"
+}
+
+interface GetColumnsProps {
+ setRowAction: React.Dispatch<React.SetStateAction<DataTableRowAction<VendorData> | null>>
+ setSelectedVendorIds: React.Dispatch<React.SetStateAction<number[]>> // Changed to array
+}
+
+/** getColumns: return array of ColumnDef for 'vendors' data */
+export function getColumns({
+ setRowAction,
+ setSelectedVendorIds, // Changed parameter name
+}: GetColumnsProps): ColumnDef<VendorData>[] {
+ return [
+ // MULTIPLE SELECT COLUMN
+ {
+ id: "select",
+ enableSorting: false,
+ enableHiding: false,
+ size: 40,
+ // Add checkbox in header for select all functionality
+ header: ({ table }) => (
+ <Checkbox
+ checked={
+ table.getFilteredSelectedRowModel().rows.length > 0 &&
+ table.getFilteredSelectedRowModel().rows.length === table.getFilteredRowModel().rows.length
+ }
+ onCheckedChange={(checked) => {
+ table.toggleAllRowsSelected(!!checked)
+
+ // Update selectedVendorIds based on all rows selection
+ if (checked) {
+ const allIds = table.getFilteredRowModel().rows.map(row => row.original.id)
+ setSelectedVendorIds(allIds)
+ } else {
+ setSelectedVendorIds([])
+ }
+ }}
+ aria-label="Select all"
+ />
+ ),
+ cell: ({ row }) => {
+ const isSelected = row.getIsSelected()
+
+ return (
+ <Checkbox
+ checked={isSelected}
+ onCheckedChange={(checked) => {
+ row.toggleSelected(!!checked)
+
+ // Update the selectedVendorIds state by adding or removing this ID
+ setSelectedVendorIds(prevIds => {
+ if (checked) {
+ // Add this ID if it doesn't exist
+ return prevIds.includes(row.original.id)
+ ? prevIds
+ : [...prevIds, row.original.id]
+ } else {
+ // Remove this ID
+ return prevIds.filter(id => id !== row.original.id)
+ }
+ })
+ }}
+ aria-label="Select row"
+ />
+ )
+ },
+ },
+
+ // Vendor Name
+ {
+ accessorKey: "vendorName",
+ header: ({ column }) => (
+ <ClientDataTableColumnHeaderSimple column={column} title="Vendor Name" />
+ ),
+ cell: ({ row }) => row.getValue("vendorName"),
+ },
+
+ // Vendor Code
+ {
+ accessorKey: "vendorCode",
+ header: ({ column }) => (
+ <ClientDataTableColumnHeaderSimple column={column} title="Vendor Code" />
+ ),
+ cell: ({ row }) => row.getValue("vendorCode"),
+ },
+
+ // Status
+ {
+ accessorKey: "status",
+ header: ({ column }) => (
+ <ClientDataTableColumnHeaderSimple column={column} title="Status" />
+ ),
+ cell: ({ row }) => row.getValue("status"),
+ },
+
+ // Country
+ {
+ accessorKey: "country",
+ header: ({ column }) => (
+ <ClientDataTableColumnHeaderSimple column={column} title="Country" />
+ ),
+ cell: ({ row }) => row.getValue("country"),
+ },
+
+ // Email
+ {
+ accessorKey: "email",
+ header: ({ column }) => (
+ <ClientDataTableColumnHeaderSimple column={column} title="Email" />
+ ),
+ cell: ({ row }) => row.getValue("email"),
+ },
+
+ // Phone
+ {
+ accessorKey: "phone",
+ header: ({ column }) => (
+ <ClientDataTableColumnHeaderSimple column={column} title="Phone" />
+ ),
+ cell: ({ row }) => row.getValue("phone"),
+ },
+
+ // Created At
+ {
+ accessorKey: "createdAt",
+ header: ({ column }) => (
+ <ClientDataTableColumnHeaderSimple column={column} title="Created At" />
+ ),
+ cell: ({ cell }) => formatDate(cell.getValue() as Date),
+ },
+
+ // Updated At
+ {
+ accessorKey: "updatedAt",
+ header: ({ column }) => (
+ <ClientDataTableColumnHeaderSimple column={column} title="Updated At" />
+ ),
+ cell: ({ cell }) => formatDate(cell.getValue() as Date),
+ },
+ ]
+} \ No newline at end of file
diff --git a/lib/rfqs-ship/vendor-table/vendor-list/vendor-list-table.tsx b/lib/rfqs-ship/vendor-table/vendor-list/vendor-list-table.tsx
new file mode 100644
index 00000000..e34a5052
--- /dev/null
+++ b/lib/rfqs-ship/vendor-table/vendor-list/vendor-list-table.tsx
@@ -0,0 +1,142 @@
+"use client"
+
+import * as React from "react"
+import { ClientDataTable } from "@/components/client-data-table/data-table"
+import { DataTableRowAction, getColumns } from "./vendor-list-table-column"
+import { DataTableAdvancedFilterField } from "@/types/table"
+import { addItemToVendors, getAllVendors } from "../../service"
+import { Loader2, Plus } from "lucide-react"
+import { Button } from "@/components/ui/button"
+import { useToast } from "@/hooks/use-toast"
+
+export interface VendorData {
+ id: number
+ vendorName: string
+ vendorCode: string | null
+ taxId: string
+ address: string | null
+ country: string | null
+ phone: string | null
+ email: string | null
+ website: string | null
+ status: string
+ createdAt: Date
+ updatedAt: Date
+}
+
+interface VendorsListTableProps {
+ rfqId: number
+}
+
+export function VendorsListTable({ rfqId }: VendorsListTableProps) {
+ const { toast } = useToast()
+ const [rowAction, setRowAction] =
+ React.useState<DataTableRowAction<VendorData> | null>(null)
+
+ // Changed to array for multiple selection
+ const [selectedVendorIds, setSelectedVendorIds] = React.useState<number[]>([])
+ const [isSubmitting, setIsSubmitting] = React.useState(false)
+
+ const columns = React.useMemo(
+ () => getColumns({ setRowAction, setSelectedVendorIds }),
+ [setRowAction, setSelectedVendorIds]
+ )
+
+ const [vendors, setVendors] = React.useState<VendorData[]>([])
+ const [isLoading, setIsLoading] = React.useState(false)
+
+ React.useEffect(() => {
+ async function loadAllVendors() {
+ setIsLoading(true)
+ try {
+ const allVendors = await getAllVendors()
+ setVendors(allVendors)
+ } catch (error) {
+ console.error("협력업체 목록 로드 오류:", error)
+ toast({
+ title: "Error",
+ description: "Failed to load vendors",
+ variant: "destructive",
+ })
+ } finally {
+ setIsLoading(false)
+ }
+ }
+ loadAllVendors()
+ }, [toast])
+
+ const advancedFilterFields: DataTableAdvancedFilterField<VendorData>[] = []
+
+ async function handleAddVendors() {
+ if (selectedVendorIds.length === 0) return // Safety check
+
+ setIsSubmitting(true)
+ try {
+ // Update to use the multiple vendor service
+ const result = await addItemToVendors(rfqId, selectedVendorIds)
+
+ if (result.success) {
+ toast({
+ title: "Success",
+ description: `Added items to ${selectedVendorIds.length} vendors`,
+ })
+ // Reset selection after successful addition
+ setSelectedVendorIds([])
+ } else {
+ toast({
+ title: "Error",
+ description: result.error || "Failed to add items to vendors",
+ variant: "destructive",
+ })
+ }
+ } catch (err) {
+ console.error("Failed to add vendors:", err)
+ toast({
+ title: "Error",
+ description: "An unexpected error occurred",
+ variant: "destructive",
+ })
+ } finally {
+ setIsSubmitting(false)
+ }
+ }
+
+ // If loading, show a flex container that fills the parent and centers the spinner
+ if (isLoading) {
+ return (
+ <div className="flex h-full w-full items-center justify-center">
+ <Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
+ </div>
+ )
+ }
+
+ // Otherwise, show the table
+ return (
+ <ClientDataTable
+ data={vendors}
+ columns={columns}
+ advancedFilterFields={advancedFilterFields}
+ >
+ <div className="flex items-center gap-2">
+ <Button
+ variant="default"
+ size="sm"
+ onClick={handleAddVendors}
+ disabled={selectedVendorIds.length === 0 || isSubmitting}
+ >
+ {isSubmitting ? (
+ <>
+ <Loader2 className="mr-2 h-4 w-4 animate-spin" />
+ Adding...
+ </>
+ ) : (
+ <>
+ <Plus className="mr-2 h-4 w-4" />
+ Add Vendors ({selectedVendorIds.length})
+ </>
+ )}
+ </Button>
+ </div>
+ </ClientDataTable>
+ )
+} \ No newline at end of file
diff --git a/lib/rfqs-ship/vendor-table/vendors-table-columns.tsx b/lib/rfqs-ship/vendor-table/vendors-table-columns.tsx
new file mode 100644
index 00000000..8b2e01cc
--- /dev/null
+++ b/lib/rfqs-ship/vendor-table/vendors-table-columns.tsx
@@ -0,0 +1,255 @@
+"use client"
+
+import * as React from "react"
+import { type DataTableRowAction } from "@/types/table"
+import { type ColumnDef } from "@tanstack/react-table"
+import { Ellipsis, MessageSquare } from "lucide-react"
+
+import { formatDate } from "@/lib/utils"
+import { Badge } from "@/components/ui/badge"
+import { Button } from "@/components/ui/button"
+import { Checkbox } from "@/components/ui/checkbox"
+import {
+ DropdownMenu,
+ DropdownMenuContent,
+ DropdownMenuItem,
+ DropdownMenuTrigger,
+} from "@/components/ui/dropdown-menu"
+import { useRouter } from "next/navigation"
+
+import { DataTableColumnHeaderSimple } from "@/components/data-table/data-table-column-simple-header"
+import { RfqShipVendorRow, rfqShipColumnsConfig } from "@/config/vendorRfqShipColumnsConfig"
+
+type NextRouter = ReturnType<typeof useRouter>;
+
+interface GetColumnsProps {
+ setRowAction: React.Dispatch<React.SetStateAction<DataTableRowAction<RfqShipVendorRow> | null>>;
+ router: NextRouter;
+ openCommentSheet: (rfqId: number) => void;
+}
+
+/**
+ * tanstack table 컬럼 정의 (중첩 헤더 버전)
+ */
+export function getColumns({ setRowAction, router, openCommentSheet }: GetColumnsProps): ColumnDef<RfqShipVendorRow>[] {
+ // ----------------------------------------------------------------
+ // 1) select 컬럼 (체크박스)
+ // ----------------------------------------------------------------
+ const selectColumn: ColumnDef<RfqShipVendorRow> = {
+ 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) 그룹별로 컬럼 구성
+ // ----------------------------------------------------------------
+ const groupMap: Record<string, ColumnDef<RfqShipVendorRow>[]> = {}
+
+ rfqShipColumnsConfig.forEach((cfg) => {
+ // 그룹이 없으면 "_noGroup" 처리
+ const groupName = cfg.group || "_noGroup"
+
+ if (!groupMap[groupName]) {
+ groupMap[groupName] = []
+ }
+
+ // 컬럼 정의
+ const column: ColumnDef<RfqShipVendorRow> = {
+ accessorKey: cfg.id,
+ enableResizing: true,
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title={cfg.label} />
+ ),
+ meta: {
+ excelHeader: cfg.excelHeader,
+ group: cfg.group,
+ type: cfg.type,
+ },
+ cell: ({ row, cell }) => {
+ // commercialResponseStatus는 배지로 표시
+ if (cfg.id === "commercialResponseStatus") {
+ const status = row.getValue(cfg.id) as string | null
+ if (!status) return null
+
+ // 상태에 따라 배지 색상 지정
+ const variant =
+ status === "SUBMITTED" ? "secondary" :
+ status === "DRAFT" ? "outline" :
+ status === "REJECTED" ? "destructive" : "default"
+
+ return (
+ <Badge variant={variant}>
+ {status}
+ </Badge>
+ )
+ }
+
+ // totalPrice는 통화와 함께 표시
+ if (cfg.id === "totalPrice") {
+ const price = row.getValue(cfg.id) as number | null
+ const currency = row.getValue("currency") as string | null
+
+ if (!price) return null
+
+ return `${currency || ''} ${price.toLocaleString()}`
+ }
+
+ // 날짜 포맷팅
+ if (cfg.id === "respondedAt") {
+ const dateVal = cell.getValue() as Date | null
+ if (!dateVal) return null
+ return formatDate(dateVal)
+ }
+
+ // 기본 데이터 표시
+ return row.getValue(cfg.id) ?? ""
+ },
+ }
+
+ // 그룹에 추가
+ groupMap[groupName].push(column)
+ })
+
+ // ----------------------------------------------------------------
+ // 3) 코멘트 컬럼
+ // ----------------------------------------------------------------
+ const commentsColumn: ColumnDef<RfqShipVendorRow> = {
+ 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
+ }
+
+ // ----------------------------------------------------------------
+ // 4) 액션 컬럼
+ // ----------------------------------------------------------------
+ const actionsColumn: ColumnDef<RfqShipVendorRow> = {
+ id: "actions",
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="Actions" />
+ ),
+ cell: ({ row }) => {
+ const vendor = row.original
+
+ return (
+ <DropdownMenu>
+ <DropdownMenuTrigger asChild>
+ <Button
+ variant="ghost"
+ className="flex h-8 w-8 p-0 data-[state=open]:bg-muted"
+ >
+ <Ellipsis className="h-4 w-4" />
+ <span className="sr-only">Open menu</span>
+ </Button>
+ </DropdownMenuTrigger>
+ <DropdownMenuContent align="end" className="w-[160px]">
+ <DropdownMenuItem
+ onClick={() => {
+ // 정보 보기
+ router.push(`/vendors/${vendor.vendorId}`)
+ }}
+ >
+ 정보 보기
+ </DropdownMenuItem>
+ <DropdownMenuItem
+ onClick={() => {
+ // CBE 요청
+ setRowAction({ row, type: "invite" })
+ }}
+ >
+ CBE 요청
+ </DropdownMenuItem>
+ </DropdownMenuContent>
+ </DropdownMenu>
+ )
+ }
+ }
+
+ // ----------------------------------------------------------------
+ // 5) 그룹별 컬럼 생성
+ // ----------------------------------------------------------------
+ const groupedColumns: ColumnDef<RfqShipVendorRow>[] = []
+
+ // 그룹별로 컬럼 생성
+ Object.entries(groupMap).forEach(([groupName, columns]) => {
+ if (groupName === "_noGroup") {
+ // 그룹 없는 컬럼은 바로 추가
+ groupedColumns.push(...columns)
+ } else {
+ // 그룹 있는 컬럼은 그룹으로 묶기
+ groupedColumns.push({
+ id: groupName,
+ header: groupName,
+ columns: columns,
+ })
+ }
+ })
+
+ // ----------------------------------------------------------------
+ // 6) 최종 컬럼 배열
+ // ----------------------------------------------------------------
+ return [
+ selectColumn,
+ ...groupedColumns,
+ commentsColumn,
+ actionsColumn
+ ]
+} \ No newline at end of file
diff --git a/lib/rfqs-ship/vendor-table/vendors-table-floating-bar.tsx b/lib/rfqs-ship/vendor-table/vendors-table-floating-bar.tsx
new file mode 100644
index 00000000..dc3e4cb0
--- /dev/null
+++ b/lib/rfqs-ship/vendor-table/vendors-table-floating-bar.tsx
@@ -0,0 +1,112 @@
+"use client"
+
+import * as React from "react"
+import { type Table } from "@tanstack/react-table"
+import {
+ X,
+} from "lucide-react"
+
+import { Button } from "@/components/ui/button"
+import { Portal } from "@/components/ui/portal"
+import { Separator } from "@/components/ui/separator"
+import {
+ Tooltip,
+ TooltipContent,
+ TooltipTrigger,
+} from "@/components/ui/tooltip"
+import { Kbd } from "@/components/kbd"
+
+import { ActionConfirmDialog } from "@/components/ui/action-dialog"
+import { MatchedVendorRow } from "@/config/vendorRfbColumnsConfig"
+
+interface VendorsTableFloatingBarProps {
+ table: Table<MatchedVendorRow>
+}
+
+
+export function VendorsTableFloatingBar({ table }: VendorsTableFloatingBarProps) {
+ const rows = table.getFilteredSelectedRowModel().rows
+
+ // Clear selection on Escape key press
+ React.useEffect(() => {
+ function handleKeyDown(event: KeyboardEvent) {
+ if (event.key === "Escape") {
+ table.toggleAllRowsSelected(false)
+ }
+ }
+
+ window.addEventListener("keydown", handleKeyDown)
+ return () => window.removeEventListener("keydown", handleKeyDown)
+ }, [table])
+
+
+
+ // 공용 confirm dialog state
+ const [confirmDialogOpen, setConfirmDialogOpen] = React.useState(false)
+ const [confirmProps, setConfirmProps] = React.useState<{
+ title: string
+ description?: string
+ onConfirm: () => Promise<void> | void
+ }>({
+ title: "",
+ description: "",
+ onConfirm: () => { },
+ })
+
+
+
+
+
+ return (
+ <Portal >
+ <div className="fixed inset-x-0 bottom-10 z-50 mx-auto w-fit px-2.5" style={{ bottom: '1.5rem' }}>
+ <div className="w-full overflow-x-auto">
+ <div className="mx-auto flex w-fit items-center gap-2 rounded-md border bg-background p-2 text-foreground shadow">
+ <div className="flex h-7 items-center rounded-md border border-dashed pl-2.5 pr-1">
+ <span className="whitespace-nowrap text-xs">
+ {rows.length} selected
+ </span>
+ <Separator orientation="vertical" className="ml-2 mr-1" />
+ <Tooltip>
+ <TooltipTrigger asChild>
+ <Button
+ variant="ghost"
+ size="icon"
+ className="size-5 hover:border"
+ onClick={() => table.toggleAllRowsSelected(false)}
+ >
+ <X className="size-3.5 shrink-0" aria-hidden="true" />
+ </Button>
+ </TooltipTrigger>
+ <TooltipContent className="flex items-center border bg-accent px-2 py-1 font-semibold text-foreground dark:bg-zinc-900">
+ <p className="mr-2">Clear selection</p>
+ <Kbd abbrTitle="Escape" variant="outline">
+ Esc
+ </Kbd>
+ </TooltipContent>
+ </Tooltip>
+ </div>
+
+ </div>
+ </div>
+ </div>
+
+
+ {/* 공용 Confirm Dialog */}
+ <ActionConfirmDialog
+ open={confirmDialogOpen}
+ onOpenChange={setConfirmDialogOpen}
+ title={confirmProps.title}
+ description={confirmProps.description}
+ onConfirm={confirmProps.onConfirm}
+ isLoading={false}
+ confirmLabel={
+ "Confirm"
+ }
+ confirmVariant={
+ "default"
+ }
+ />
+ </Portal>
+ )
+}
diff --git a/lib/rfqs-ship/vendor-table/vendors-table-toolbar-actions.tsx b/lib/rfqs-ship/vendor-table/vendors-table-toolbar-actions.tsx
new file mode 100644
index 00000000..a8825cc7
--- /dev/null
+++ b/lib/rfqs-ship/vendor-table/vendors-table-toolbar-actions.tsx
@@ -0,0 +1,62 @@
+"use client"
+
+import * as React from "react"
+import { type Table } from "@tanstack/react-table"
+
+import { MatchedVendorRow } from "@/config/vendorRfbColumnsConfig"
+import { InviteVendorsDialog } from "./invite-vendors-dialog"
+import { AddVendorDialog } from "./add-vendor-dialog"
+import { Button } from "@/components/ui/button"
+import { RfqType } from "@/lib/rfqs-ship/validations"
+
+interface VendorsTableToolbarActionsProps {
+ table: Table<MatchedVendorRow>
+ rfqId: number
+ rfqType: RfqType
+}
+
+export function VendorsTableToolbarActions({
+ table,
+ rfqId,
+ rfqType,
+}: VendorsTableToolbarActionsProps) {
+ // 선택된 행이 있는지 확인
+ const rowSelection = table.getState().rowSelection
+ const selectedRows = Object.keys(rowSelection).length
+ const hasSelectedRows = selectedRows > 0
+
+ // 선택된 벤더 목록
+ const selectedVendors = React.useMemo(() => {
+ return Object.keys(rowSelection).map((id) =>
+ table.getRow(id).original
+ )
+ }, [rowSelection, table])
+
+ return (
+ <div className="flex items-center justify-between gap-2">
+ <div className="flex items-center gap-2">
+ <AddVendorDialog rfqId={rfqId} />
+ </div>
+ <div className="flex items-center gap-2">
+ {hasSelectedRows && (
+ <InviteVendorsDialog
+ rfqId={rfqId}
+ rfqType={rfqType}
+ vendors={selectedVendors}
+ directCbe={true}
+ onSuccess={() => table.toggleAllRowsSelected(false)}
+ >
+ <Button
+ variant="default"
+ size="sm"
+ className="h-8"
+ disabled={!hasSelectedRows}
+ >
+ CBE 요청 ({selectedRows})
+ </Button>
+ </InviteVendorsDialog>
+ )}
+ </div>
+ </div>
+ )
+} \ No newline at end of file
diff --git a/lib/rfqs-ship/vendor-table/vendors-table.tsx b/lib/rfqs-ship/vendor-table/vendors-table.tsx
new file mode 100644
index 00000000..4e6b3e81
--- /dev/null
+++ b/lib/rfqs-ship/vendor-table/vendors-table.tsx
@@ -0,0 +1,225 @@
+"use client"
+
+import * as React from "react"
+import { useRouter } from "next/navigation"
+import { useSession } from "next-auth/react"
+import type {
+ DataTableAdvancedFilterField,
+ DataTableFilterField,
+ DataTableRowAction,
+} from "@/types/table"
+
+import { toSentenceCase } from "@/lib/utils"
+import { useDataTable } from "@/hooks/use-data-table"
+import { DataTable } from "@/components/data-table/data-table"
+import { DataTableAdvancedToolbar } from "@/components/data-table/data-table-advanced-toolbar"
+import { getColumns } from "./vendors-table-columns"
+import { vendors } from "@/db/schema/vendors"
+import { VendorsTableToolbarActions } from "./vendors-table-toolbar-actions"
+import { fetchRfqAttachmentsbyCommentId, getMatchedVendors } from "../service"
+import { InviteVendorsDialog } from "./invite-vendors-dialog"
+import { CommentSheet } from "./comments-sheet"
+import { RfqShipVendorRow } from "@/config/vendorRfqShipColumnsConfig"
+import { RfqType } from "../validations"
+import { toast } from "sonner"
+
+// CommentSheet와 호환되는 코멘트 타입 정의
+interface RfqShipComment {
+ id: number;
+ commentText: string;
+ vendorId?: number;
+ createdAt: Date;
+ commentedBy?: number;
+ attachments?: {
+ id: number;
+ fileName: string;
+ filePath: string;
+ }[];
+}
+
+interface VendorsTableProps {
+ promises: Promise<[Awaited<ReturnType<typeof getMatchedVendors>>]>
+ rfqId: number
+ rfqType: RfqType
+}
+
+export function MatchedVendorsTable({ promises, rfqId, rfqType }: VendorsTableProps) {
+ const { data: session } = useSession()
+
+ // 1) Suspense로 받아온 데이터
+ const [{ data, pageCount }] = React.use(promises)
+
+ // 2) Row 액션 상태
+ const [rowAction, setRowAction] = React.useState<
+ DataTableRowAction<RfqShipVendorRow> | null
+ >(null)
+
+ // router 획득
+ const router = useRouter()
+
+ // 3) CommentSheet 에 넣을 상태
+ const [initialComments, setInitialComments] = React.useState<
+ RfqShipComment[]
+ >([])
+
+ const [isLoadingComments, setIsLoadingComments] = React.useState(false)
+
+ const [commentSheetOpen, setCommentSheetOpen] = React.useState(false)
+ const [selectedVendorIdForComments, setSelectedVendorIdForComments] =
+ React.useState<number | null>(null)
+
+ // 4) rowAction이 바뀌면, type이 "comments"인지 확인 후 open
+ React.useEffect(() => {
+ if (rowAction?.type === "comments") {
+ openCommentSheet(rowAction.row.original.id)
+ }
+ }, [rowAction])
+
+ // 5) 댓글 시트 오픈 함수
+ async function openCommentSheet(vendorId: number) {
+ // Clear previous comments
+ setInitialComments([])
+
+ // Start loading
+ setIsLoadingComments(true)
+
+ // Open the sheet immediately with loading state
+ setSelectedVendorIdForComments(vendorId)
+ setCommentSheetOpen(true)
+
+ // (a) 현재 Row의 comments 불러옴
+ const comments = rowAction?.row.original.comments
+
+ try {
+ if (comments && comments.length > 0) {
+ // (b) 각 comment마다 첨부파일 fetch
+ const commentWithAttachments: RfqShipComment[] = await Promise.all(
+ comments.map(async (c) => {
+ const attachments = await fetchRfqAttachmentsbyCommentId(c.id)
+ return {
+ ...c,
+ createdAt: c.createdAt || new Date(),
+ attachments,
+ }
+ })
+ )
+ setInitialComments(commentWithAttachments)
+ }
+ } catch (error) {
+ console.error("Error loading comments:", error)
+ toast.error("Failed to load comments")
+ } finally {
+ // End loading regardless of success/failure
+ setIsLoadingComments(false)
+ }
+ }
+
+ // 6) 컬럼 정의 (memo)
+ const columns = React.useMemo(
+ () =>
+ getColumns({
+ setRowAction,
+ router,
+ openCommentSheet,
+ }),
+ [router]
+ )
+
+ // 7) 필터 정의
+ const filterFields: DataTableFilterField<RfqShipVendorRow>[] = []
+
+ const advancedFilterFields: DataTableAdvancedFilterField<RfqShipVendorRow>[] = [
+ { id: "vendorName", label: "Vendor Name", type: "text" },
+ { id: "vendorCode", label: "Vendor Code", type: "text" },
+ { id: "projectCode", label: "Project Code", type: "text" },
+ { id: "projectName", label: "Project Name", type: "text" },
+ {
+ id: "vendorStatus",
+ label: "Vendor Status",
+ type: "multi-select",
+ options: vendors.status.enumValues.map((status) => ({
+ label: toSentenceCase(status),
+ value: status,
+ })),
+ },
+ {
+ id: "commercialResponseStatus",
+ label: "Response Status",
+ type: "multi-select",
+ options: ["DRAFT", "SUBMITTED", "REJECTED", "APPROVED"].map((s) => ({
+ label: s,
+ value: s,
+ })),
+ },
+ { id: "respondedAt", label: "Response Date", type: "date" },
+ ]
+
+ // 8) 테이블 생성
+ const { table } = useDataTable({
+ data: data as unknown as RfqShipVendorRow[], // 타입 변환
+ columns,
+ pageCount,
+ filterFields,
+ enablePinning: true,
+ enableAdvancedFilter: true,
+ initialState: {
+ sorting: [{ id: "respondedAt", desc: true }],
+ columnPinning: { right: ["actions"] },
+ },
+ // 행의 고유 ID
+ getRowId: (originalRow) => String(originalRow.id),
+ shallow: false,
+ clearOnDefault: true,
+ })
+
+ // 세션에서 userId 추출하고 숫자로 변환
+ const currentUserId = session?.user?.id ? parseInt(session.user.id, 10) : 0
+
+ return (
+ <>
+ <DataTable
+ table={table}
+ >
+ <DataTableAdvancedToolbar
+ table={table}
+ filterFields={advancedFilterFields}
+ shallow={false}
+ >
+ <VendorsTableToolbarActions
+ table={table as any}
+ rfqId={rfqId}
+ rfqType={rfqType}
+ />
+ </DataTableAdvancedToolbar>
+ </DataTable>
+
+ {/* CBE 요청 다이얼로그 */}
+ <InviteVendorsDialog
+ vendors={rowAction?.row.original ? [rowAction?.row.original as any] : []}
+ onOpenChange={() => setRowAction(null)}
+ rfqId={rfqId}
+ rfqType={rfqType}
+ open={rowAction?.type === "invite"}
+ showTrigger={false}
+ currentUser={session?.user}
+ directCbe={true}
+ />
+
+ {/* 댓글 시트 */}
+ <CommentSheet
+ open={commentSheetOpen}
+ onOpenChange={setCommentSheetOpen}
+ initialComments={initialComments as any}
+ rfqId={rfqId}
+ vendorId={selectedVendorIdForComments ?? 0}
+ currentUserId={currentUserId}
+ isLoading={isLoadingComments}
+ onCommentsUpdated={(updatedComments: any) => {
+ // Row 의 comments 필드도 업데이트
+ if (!rowAction?.row) return
+ rowAction.row.original.comments = updatedComments
+ }}
+ />
+ </>
+ )
+} \ No newline at end of file