summaryrefslogtreecommitdiff
path: root/lib/tech-vendor-rfq-response/vendor-tbe-table
diff options
context:
space:
mode:
Diffstat (limited to 'lib/tech-vendor-rfq-response/vendor-tbe-table')
-rw-r--r--lib/tech-vendor-rfq-response/vendor-tbe-table/comments-sheet.tsx348
-rw-r--r--lib/tech-vendor-rfq-response/vendor-tbe-table/rfq-detail-dialog.tsx75
-rw-r--r--lib/tech-vendor-rfq-response/vendor-tbe-table/tbe-table-columns.tsx350
-rw-r--r--lib/tech-vendor-rfq-response/vendor-tbe-table/tbe-table.tsx191
-rw-r--r--lib/tech-vendor-rfq-response/vendor-tbe-table/tbeFileHandler.tsx354
5 files changed, 1318 insertions, 0 deletions
diff --git a/lib/tech-vendor-rfq-response/vendor-tbe-table/comments-sheet.tsx b/lib/tech-vendor-rfq-response/vendor-tbe-table/comments-sheet.tsx
new file mode 100644
index 00000000..5e27a4aa
--- /dev/null
+++ b/lib/tech-vendor-rfq-response/vendor-tbe-table/comments-sheet.tsx
@@ -0,0 +1,348 @@
+"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 { Loader, 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"
+
+// DB 스키마에서 필요한 타입들을 가져온다고 가정
+// (실제 프로젝트에 맞춰 import를 수정하세요.)
+import { RfqWithAll } from "@/db/schema/rfq"
+import { createRfqCommentWithAttachments } from "../../rfqs-tech/service"
+import { formatDate } from "@/lib/utils"
+
+// 코멘트 + 첨부파일 구조 (단순 예시)
+// 실제 DB 스키마에 맞춰 조정
+export interface TbeComment {
+ id: number
+ commentText: string
+ commentedBy?: number
+ createdAt?: string | Date
+ attachments?: {
+ id: number
+ fileName: string
+ filePath: string
+ }[]
+}
+
+interface CommentSheetProps extends React.ComponentPropsWithRef<typeof Sheet> {
+ /** 코멘트를 작성할 RFQ 정보 */
+ /** 이미 존재하는 모든 코멘트 목록 (서버에서 불러와 주입) */
+ initialComments?: TbeComment[]
+
+ /** 사용자(작성자) ID (로그인 세션 등에서 가져옴) */
+ currentUserId: number
+ rfqId:number
+ tbeId:number
+ vendorId:number
+ /** 댓글 저장 후 갱신용 콜백 (옵션) */
+ onCommentsUpdated?: (comments: TbeComment[]) => void
+ isLoading?: boolean // New prop
+
+}
+
+// 새 코멘트 작성 폼 스키마
+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 = [],
+ tbeId,
+ currentUserId,
+ onCommentsUpdated,
+ isLoading = false, // Default to false
+ ...props
+}: CommentSheetProps) {
+ const [comments, setComments] = React.useState<TbeComment[]>(initialComments)
+ const [isPending, startTransition] = React.useTransition()
+
+ React.useEffect(() => {
+ setComments(initialComments)
+ }, [initialComments])
+
+
+ // RHF 세팅
+ const form = useForm<CommentFormValues>({
+ resolver: zodResolver(commentFormSchema),
+ defaultValues: {
+ commentText: "",
+ newFiles: []
+ }
+ })
+
+ // formFieldArray 예시 (파일 목록)
+ const { fields: newFileFields, append, remove } = useFieldArray({
+ control: form.control,
+ name: "newFiles"
+ })
+
+ // 1) 기존 코멘트 + 첨부 보여주기
+ // 간단히 테이블 하나로 표현
+ // 실제로는 Bubble 형태의 UI, Accordion, Timeline 등 다양하게 구성할 수 있음
+ function renderExistingComments() {
+ if (isLoading) {
+ return (
+ <div className="flex justify-center items-center h-32">
+ <Loader2 className="h-4 w-4 animate-spin text-muted-foreground" />
+ <span className="ml-2 text-sm text-muted-foreground">Loading comments...</span>
+ </div>
+ )
+ }
+
+ if (comments.length === 0) {
+ return <p className="text-sm text-muted-foreground">No comments yet</p>
+ }
+
+ 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 || c.attachments.length === 0) && (
+ <span className="text-sm text-muted-foreground">No files</span>
+ )}
+ {c.attachments && c.attachments.length > 0 && (
+ <div className="flex flex-col gap-1">
+ {c.attachments.map((att) => (
+ <div key={att.id} className="flex items-center gap-2">
+ <a
+ href={att.filePath}
+ 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.commentedBy ?? "-"}
+ </TableCell>
+ </TableRow>
+ ))}
+ </TableBody>
+ </Table>
+ )
+ }
+
+ // 2) 새 파일 Drop
+ function handleDropAccepted(files: File[]) {
+ // 드롭된 File[]을 RHF field array에 추가
+ const toAppend = files.map((f) => f)
+ append(toAppend)
+ }
+
+
+ // 3) 저장(Submit)
+ async function onSubmit(data: CommentFormValues) {
+
+ if (!rfqId) return
+ startTransition(async () => {
+ try {
+ // 서버 액션 호출
+ const res = await createRfqCommentWithAttachments({
+ rfqId: rfqId,
+ vendorId: vendorId, // 필요시 세팅
+ commentText: data.commentText,
+ commentedBy: currentUserId,
+ evaluationId: tbeId, // 필요시 세팅
+ files: data.newFiles
+ })
+
+ if (!res.ok) {
+ throw new Error("Failed to create comment")
+ }
+
+ toast.success("Comment created")
+
+ // 새 코멘트를 다시 불러오거나,
+ // 여기서는 임시로 "새로운 코멘트가 추가됐다" 라고 가정하여 클라이언트에서 상태 업데이트
+ const newComment: TbeComment = {
+ id: res.commentId, // 서버에서 반환된 commentId
+ commentText: data.commentText,
+ commentedBy: currentUserId,
+ createdAt: new Date().toISOString(),
+ attachments: (data.newFiles?.map((f, idx) => ({
+ id: Math.random() * 100000,
+ 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}>
+ <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 (파일 첨부) */}
+ <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 && <Loader className="mr-2 h-4 w-4 animate-spin" />}
+ Save
+ </Button>
+ </SheetFooter>
+ </form>
+ </Form>
+ </SheetContent>
+ </Sheet>
+ )
+} \ No newline at end of file
diff --git a/lib/tech-vendor-rfq-response/vendor-tbe-table/rfq-detail-dialog.tsx b/lib/tech-vendor-rfq-response/vendor-tbe-table/rfq-detail-dialog.tsx
new file mode 100644
index 00000000..26698c2e
--- /dev/null
+++ b/lib/tech-vendor-rfq-response/vendor-tbe-table/rfq-detail-dialog.tsx
@@ -0,0 +1,75 @@
+"use client"
+
+import * as React from "react"
+import {
+ Dialog,
+ DialogContent,
+ DialogHeader,
+ DialogTitle,
+} from "@/components/ui/dialog"
+import { Badge } from "@/components/ui/badge"
+import { formatDateTime } from "@/lib/utils"
+import { CalendarClock } from "lucide-react"
+import { RfqItemsTable } from "../vendor-cbe-table/rfq-items-table/rfq-items-table"
+import { TbeVendorFields } from "@/config/vendorTbeColumnsConfig"
+
+interface RfqDeailDialogProps {
+ isOpen: boolean
+ onOpenChange: (open: boolean) => void
+ rfqId: number | null
+ rfq: TbeVendorFields | null
+}
+
+export function RfqDeailDialog({
+ isOpen,
+ onOpenChange,
+ rfqId,
+ rfq,
+}: RfqDeailDialogProps) {
+ return (
+ <Dialog open={isOpen} onOpenChange={onOpenChange}>
+ <DialogContent className="max-w-[90wv] sm:max-h-[80vh] overflow-auto" style={{maxWidth:1000, height:480}}>
+ <DialogHeader>
+ <div className="flex flex-col space-y-2">
+ <DialogTitle>{rfq && rfq.rfqCode} Detail</DialogTitle>
+ {rfq && (
+ <div className="flex flex-col space-y-3 mt-2">
+ <div className="text-sm text-muted-foreground">
+ <span className="font-medium text-foreground">{rfq.rfqDescription && rfq.rfqDescription}</span>
+ </div>
+
+ {/* 정보를 두 행으로 나누어 표시 */}
+ <div className="flex flex-col space-y-2 sm:space-y-0 sm:flex-row sm:justify-between sm:items-center">
+ {/* 첫 번째 행: 상태 배지 */}
+ <div className="flex items-center flex-wrap gap-2">
+ {rfq.vendorStatus && (
+ <Badge variant="outline">
+ {rfq.rfqStatus}
+ </Badge>
+ )}
+ </div>
+
+ {/* 두 번째 행: Due Date를 강조 표시 */}
+ {rfq.rfqDueDate && (
+ <div className="flex items-center">
+ <Badge variant="secondary" className="flex gap-1 text-xs py-1 px-3">
+ <CalendarClock className="h-3.5 w-3.5" />
+ <span className="font-semibold">Due Date:</span>
+ <span>{formatDateTime(rfq.rfqDueDate)}</span>
+ </Badge>
+ </div>
+ )}
+ </div>
+ </div>
+ )}
+ </div>
+ </DialogHeader>
+ {rfqId && (
+ <div className="py-4">
+ <RfqItemsTable rfqId={rfqId} />
+ </div>
+ )}
+ </DialogContent>
+ </Dialog>
+ )
+} \ No newline at end of file
diff --git a/lib/tech-vendor-rfq-response/vendor-tbe-table/tbe-table-columns.tsx b/lib/tech-vendor-rfq-response/vendor-tbe-table/tbe-table-columns.tsx
new file mode 100644
index 00000000..b880506a
--- /dev/null
+++ b/lib/tech-vendor-rfq-response/vendor-tbe-table/tbe-table-columns.tsx
@@ -0,0 +1,350 @@
+"use client"
+
+import * as React from "react"
+import { type DataTableRowAction } from "@/types/table"
+import { type ColumnDef } from "@tanstack/react-table"
+import { Download, MessageSquare, Upload } 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 {
+ tbeVendorColumnsConfig,
+ VendorTbeColumnConfig,
+ vendorTbeColumnsConfig,
+ TbeVendorFields,
+} from "@/config/vendorTbeColumnsConfig"
+
+type NextRouter = ReturnType<typeof useRouter>
+
+interface GetColumnsProps {
+ setRowAction: React.Dispatch<
+ React.SetStateAction<DataTableRowAction<TbeVendorFields> | null>
+ >
+ router: NextRouter
+ openCommentSheet: (vendorId: number) => void
+ handleDownloadTbeTemplate: (tbeId: number, vendorId: number, rfqId: number) => void
+ handleUploadTbeResponse: (tbeId: number, vendorId: number, rfqId: number, vendorResponseId:number) => void
+ openVendorContactsDialog: (rfqId: number, rfq: TbeVendorFields) => void // 수정된 시그니처
+
+}
+
+/**
+ * tanstack table 컬럼 정의 (중첩 헤더 버전)
+ */
+export function getColumns({
+ setRowAction,
+ router,
+ openCommentSheet,
+ handleDownloadTbeTemplate,
+ handleUploadTbeResponse,
+ openVendorContactsDialog
+}: GetColumnsProps): ColumnDef<TbeVendorFields>[] {
+ // ----------------------------------------------------------------
+ // 1) Select 컬럼 (체크박스)
+ // ----------------------------------------------------------------
+ const selectColumn: ColumnDef<TbeVendorFields> = {
+ 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<TbeVendorFields>[]> = {}
+
+ tbeVendorColumnsConfig.forEach((cfg) => {
+ const groupName = cfg.group || "_noGroup"
+ if (!groupMap[groupName]) {
+ groupMap[groupName] = []
+ }
+
+ // childCol: ColumnDef<TbeVendorFields>
+ const childCol: ColumnDef<TbeVendorFields> = {
+ accessorKey: cfg.id,
+ enableResizing: true,
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title={cfg.label} />
+ ),
+ meta: {
+ excelHeader: cfg.excelHeader,
+ group: cfg.group,
+ type: cfg.type,
+ },
+ maxSize: 120,
+ // 셀 렌더링
+ cell: ({ row, getValue }) => {
+ // 1) 필드값 가져오기
+ const val = getValue()
+
+ if (cfg.id === "vendorStatus") {
+ const statusVal = row.original.vendorStatus
+ if (!statusVal) return null
+ // const Icon = getStatusIcon(statusVal)
+ return (
+ <Badge variant="outline">
+ {statusVal}
+ </Badge>
+ )
+ }
+
+
+ if (cfg.id === "rfqCode") {
+ const rfq = row.original;
+ const rfqId = rfq.rfqId;
+
+ // 협력업체 이름을 클릭할 수 있는 버튼으로 렌더링
+ const handleVendorNameClick = () => {
+ if (rfqId) {
+ openVendorContactsDialog(rfqId, rfq); // vendor 전체 객체 전달
+ } else {
+ toast.error("협력업체 ID를 찾을 수 없습니다.");
+ }
+ };
+
+ return (
+ <Button
+ variant="link"
+ className="p-0 h-auto text-left font-normal justify-start hover:underline"
+ onClick={handleVendorNameClick}
+ >
+ {val as string}
+ </Button>
+ );
+ }
+ if (cfg.id === "rfqVendorStatus") {
+ const statusVal = row.original.rfqVendorStatus
+ if (!statusVal) return null
+ // const Icon = getStatusIcon(statusVal)
+ const variant = statusVal === "INVITED" ? "default" : statusVal === "REJECTED" ? "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<TbeVendorFields>[] = []
+ 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<TbeVendorFields> = {
+ id: "comments",
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="Comments" />
+ ),
+ cell: ({ row }) => {
+ const vendor = row.original
+ const commCount = vendor.comments?.filter(c => c.evaluationId === vendor.tbeId)?.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
+ }
+
+ // ----------------------------------------------------------------
+ // 4) TBE 다운로드 컬럼 - 템플릿 다운로드 기능
+ // ----------------------------------------------------------------
+ const tbeDownloadColumn: ColumnDef<TbeVendorFields> = {
+ id: "tbeDownload",
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="TBE Sheets" />
+ ),
+ cell: ({ row }) => {
+ const vendor = row.original
+ const tbeId = vendor.tbeId
+ const vendorId = vendor.vendorId
+ const rfqId = vendor.rfqId
+ const templateFileCount = vendor.templateFileCount || 0
+
+ if (!tbeId || !vendorId || !rfqId) {
+ return <div className="text-center text-muted-foreground">-</div>
+ }
+
+ // 템플릿 파일이 없으면 다운로드 버튼 비활성화
+ const isDisabled = templateFileCount <= 0
+
+ return (
+ <Button
+ variant="ghost"
+ size="sm"
+ className="relative h-8 w-8 p-0 group"
+ onClick={
+ isDisabled
+ ? undefined
+ : () => handleDownloadTbeTemplate(tbeId, vendorId, rfqId)
+ }
+ aria-label={
+ templateFileCount > 0
+ ? `TBE 템플릿 다운로드 (${templateFileCount}개)`
+ : "다운로드할 파일 없음"
+ }
+ disabled={isDisabled}
+ >
+ <Download className="h-4 w-4 text-muted-foreground group-hover:text-primary transition-colors" />
+
+ {/* 파일이 1개 이상인 경우 뱃지로 개수 표시 */}
+ {templateFileCount > 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"
+ >
+ {templateFileCount}
+ </Badge>
+ )}
+
+ <span className="sr-only">
+ {templateFileCount > 0
+ ? `TBE 템플릿 다운로드 (${templateFileCount}개)`
+ : "다운로드할 파일 없음"}
+ </span>
+ </Button>
+ )
+ },
+ enableSorting: false,
+ maxSize: 80,
+ }
+ // ----------------------------------------------------------------
+ // 5) TBE 업로드 컬럼 - 응답 업로드 기능
+ // ----------------------------------------------------------------
+ const tbeUploadColumn: ColumnDef<TbeVendorFields> = {
+ id: "tbeUpload",
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="Upload Response" />
+ ),
+ cell: ({ row }) => {
+ const vendor = row.original
+ const tbeId = vendor.tbeId
+ const vendorId = vendor.vendorId
+ const rfqId = vendor.rfqId
+ const vendorResponseId = vendor.vendorResponseId || 0
+ const status = vendor.rfqVendorStatus
+ const hasResponse = vendor.hasResponse || false
+
+
+ if (!tbeId || !vendorId || !rfqId || status === "REJECTED") {
+ return <div className="text-center text-muted-foreground">-</div>
+ }
+
+ return (
+ <div >
+ <Button
+ variant="ghost"
+ size="sm"
+ className="h-8 w-8 p-0 group relative"
+ onClick={() => handleUploadTbeResponse(tbeId, vendorId, rfqId, vendorResponseId)}
+ aria-label={hasResponse ? "TBE 응답 확인" : "TBE 응답 업로드"}
+ >
+ <div className="flex items-center justify-center relative">
+ <Upload className="h-4 w-4 text-muted-foreground group-hover:text-primary transition-colors" />
+ </div>
+ {hasResponse && (
+ <span className="absolute -top-1 -right-1 inline-flex h-2 w-2 rounded-full" style={{ backgroundColor: '#10B981' }}></span>
+ )}
+ <span className="sr-only">
+ {"TBE 응답 업로드"}
+ </span>
+ </Button>
+ </div>
+ )
+ },
+ enableSorting: false,
+ maxSize: 80
+ }
+
+ // ----------------------------------------------------------------
+ // 6) 최종 컬럼 배열
+ // ----------------------------------------------------------------
+ return [
+ selectColumn,
+ ...nestedColumns,
+ commentsColumn,
+ tbeDownloadColumn,
+ tbeUploadColumn,
+ ]
+} \ No newline at end of file
diff --git a/lib/tech-vendor-rfq-response/vendor-tbe-table/tbe-table.tsx b/lib/tech-vendor-rfq-response/vendor-tbe-table/tbe-table.tsx
new file mode 100644
index 00000000..2de2dd11
--- /dev/null
+++ b/lib/tech-vendor-rfq-response/vendor-tbe-table/tbe-table.tsx
@@ -0,0 +1,191 @@
+"use client"
+
+import * as React from "react"
+import { useRouter } from "next/navigation"
+import type {
+ DataTableAdvancedFilterField,
+ DataTableFilterField,
+ DataTableRowAction,
+} from "@/types/table"
+import { toast } from "sonner"
+import { useDataTable } from "@/hooks/use-data-table"
+import { DataTable } from "@/components/data-table/data-table"
+import { DataTableAdvancedToolbar } from "@/components/data-table/data-table-advanced-toolbar"
+import { getColumns } from "./tbe-table-columns"
+import { fetchRfqAttachmentsbyCommentId, getTBEforVendor } from "../../rfqs-tech/service"
+import { CommentSheet, TbeComment } from "./comments-sheet"
+import { TbeVendorFields } from "@/config/vendorTbeColumnsConfig"
+import { useTbeFileHandlers } from "./tbeFileHandler"
+import { useSession } from "next-auth/react"
+import { RfqDeailDialog } from "./rfq-detail-dialog"
+
+interface VendorsTableProps {
+ promises: Promise<
+ [
+ Awaited<ReturnType<typeof getTBEforVendor>>,
+ ]
+ >
+}
+
+export function TbeVendorTable({ promises }: VendorsTableProps) {
+ const { data: session } = useSession()
+ const userVendorId = session?.user?.companyId
+ const userId = Number(session?.user?.id)
+ console.log("userVendorId", userVendorId)
+ console.log("userId", userId)
+ // Suspense로 받아온 데이터
+ const [{ data, pageCount }] = React.use(promises)
+ const [rowAction, setRowAction] = React.useState<DataTableRowAction<TbeVendorFields> | null>(null)
+
+
+ // router 획득
+ const router = useRouter()
+
+ const [initialComments, setInitialComments] = React.useState<TbeComment[]>([])
+ const [isLoadingComments, setIsLoadingComments] = React.useState(false)
+
+ const [commentSheetOpen, setCommentSheetOpen] = React.useState(false)
+ const [selectedRfqIdForComments, setSelectedRfqIdForComments] = React.useState<number | null>(0)
+ const [isRfqDetailDialogOpen, setIsRfqDetailDialogOpen] = React.useState(false)
+
+ const [selectedRfqId, setSelectedRfqId] = React.useState<number | null>(null)
+ const [selectedRfq, setSelectedRfq] = React.useState<TbeVendorFields | null>(null)
+
+ const openVendorContactsDialog = (rfqId: number, rfq: TbeVendorFields) => {
+ setSelectedRfqId(rfqId)
+ setSelectedRfq(rfq)
+ setIsRfqDetailDialogOpen(true)
+ }
+
+ // TBE 파일 핸들러 훅 사용
+ const {
+ handleDownloadTbeTemplate,
+ handleUploadTbeResponse,
+ UploadDialog,
+ } = useTbeFileHandlers()
+
+ React.useEffect(() => {
+ if (rowAction?.type === "comments") {
+ // rowAction가 새로 세팅된 뒤 여기서 openCommentSheet 실행
+ openCommentSheet(Number(rowAction.row.original.tbeId))
+ }
+ }, [rowAction])
+
+ async function openCommentSheet(tbeId: number) {
+ setInitialComments([])
+ setIsLoadingComments(true)
+
+ const comments = rowAction?.row.original.comments?.filter(c => c.evaluationId === tbeId)
+
+ try {
+ if (comments && comments.length > 0) {
+ const commentWithAttachments: TbeComment[] = await Promise.all(
+ comments.map(async (c) => {
+ // 서버 액션을 사용하여 코멘트 첨부 파일 가져오기
+ const attachments = await fetchRfqAttachmentsbyCommentId(c.id)
+
+ return {
+ ...c,
+ commentedBy: userId, // DB나 API 응답에 있다고 가정
+ attachments,
+ }
+ })
+ )
+
+ setInitialComments(commentWithAttachments)
+ }
+
+ setSelectedRfqIdForComments(rowAction?.row.original.rfqId ?? null)
+ setCommentSheetOpen(true)
+
+ } catch (error) {
+ console.error("Error loading comments:", error)
+ toast.error("Failed to load comments")
+ } finally {
+ // End loading regardless of success/failure
+ setIsLoadingComments(false)
+ }
+}
+
+ // getColumns() 호출 시, 필요한 모든 핸들러 함수 주입
+ const columns = React.useMemo(
+ () => getColumns({
+ setRowAction,
+ router,
+ openCommentSheet,
+ handleDownloadTbeTemplate,
+ handleUploadTbeResponse,
+ openVendorContactsDialog
+ }),
+ [setRowAction, router, openCommentSheet, handleDownloadTbeTemplate, handleUploadTbeResponse, openVendorContactsDialog]
+ )
+
+ const filterFields: DataTableFilterField<TbeVendorFields>[] = []
+
+ const advancedFilterFields: DataTableAdvancedFilterField<TbeVendorFields>[] = [
+ { id: "rfqCode", label: "RFQ Code", type: "text" },
+ { id: "projectCode", label: "Project Code", type: "text" },
+ { id: "projectName", label: "Project Name", type: "text" },
+ { id: "rfqCode", label: "RFQ Code", type: "text" },
+ { id: "tbeResult", label: "TBE Result", type: "text" },
+ { id: "tbeNote", label: "TBE Note", type: "text" },
+ { id: "rfqCode", label: "RFQ Code", type: "text" },
+ { id: "hasResponse", label: "Response?", type: "boolean" },
+ { id: "rfqVendorUpdated", label: "Updated at", type: "date" },
+ { id: "dueDate", label: "Project Name", type: "date" },
+
+ ]
+
+ const { table } = useDataTable({
+ data,
+ columns,
+ pageCount,
+ filterFields,
+ enablePinning: true,
+ enableAdvancedFilter: true,
+ initialState: {
+ sorting: [{ id: "rfqVendorUpdated", desc: true }],
+ columnPinning: { right: ["comments", "tbeDocuments"] }, // tbeDocuments 컬럼을 우측에 고정
+ },
+ getRowId: (originalRow) => String(originalRow.rfqId),
+ shallow: false,
+ clearOnDefault: true,
+ })
+
+ return (
+ <>
+ <DataTable table={table}>
+ <DataTableAdvancedToolbar
+ table={table}
+ filterFields={advancedFilterFields}
+ shallow={false}
+ />
+ </DataTable>
+
+ {/* 코멘트 시트 */}
+ {commentSheetOpen && selectedRfqIdForComments && (
+ <CommentSheet
+ open={commentSheetOpen}
+ onOpenChange={setCommentSheetOpen}
+ rfqId={selectedRfqIdForComments}
+ tbeId={rowAction?.row.original.tbeId || 0}
+ initialComments={initialComments}
+ vendorId={userVendorId || 0}
+ currentUserId={userId || 0}
+ isLoading={isLoadingComments} // Pass the loading state
+
+ />
+ )}
+
+ <RfqDeailDialog
+ isOpen={isRfqDetailDialogOpen}
+ onOpenChange={setIsRfqDetailDialogOpen}
+ rfqId={selectedRfqId}
+ rfq={selectedRfq}
+ />
+
+ {/* TBE 파일 다이얼로그 */}
+ <UploadDialog />
+ </>
+ )
+} \ No newline at end of file
diff --git a/lib/tech-vendor-rfq-response/vendor-tbe-table/tbeFileHandler.tsx b/lib/tech-vendor-rfq-response/vendor-tbe-table/tbeFileHandler.tsx
new file mode 100644
index 00000000..6c622fd1
--- /dev/null
+++ b/lib/tech-vendor-rfq-response/vendor-tbe-table/tbeFileHandler.tsx
@@ -0,0 +1,354 @@
+"use client";
+
+import { useCallback, useState, useEffect } from "react";
+import { toast } from "sonner";
+import {
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogFooter,
+ DialogHeader,
+ DialogTitle,
+} from "@/components/ui/dialog";
+import { Button } from "@/components/ui/button";
+import {
+ fetchTbeTemplateFiles,
+ uploadTbeResponseFile,
+ getTbeSubmittedFiles,
+ getFileFromRfqAttachmentsbyid,
+} from "../../rfqs-tech/service";
+import {
+ Dropzone,
+ DropzoneDescription,
+ DropzoneInput,
+ DropzoneTitle,
+ DropzoneUploadIcon,
+ DropzoneZone,
+} from "@/components/ui/dropzone";
+import {
+ FileList,
+ FileListAction,
+ FileListDescription,
+ FileListIcon,
+ FileListInfo,
+ FileListItem,
+ FileListName,
+ FileListSize,
+} from "@/components/ui/file-list";
+import { Download, X } from "lucide-react";
+import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
+import { formatDateTime } from "@/lib/utils";
+
+export function useTbeFileHandlers() {
+ // 모달 열림 여부, 현재 선택된 IDs
+ const [isUploadDialogOpen, setIsUploadDialogOpen] = useState(false);
+ const [currentTbeId, setCurrentTbeId] = useState<number | null>(null);
+ const [currentVendorId, setCurrentVendorId] = useState<number | null>(null);
+ const [currentRfqId, setCurrentRfqId] = useState<number | null>(null);
+ const [currentvendorResponseId, setCurrentvendorResponseId] = useState<number | null>(null);
+
+
+
+ // 로딩 상태들
+ const [isLoading, setIsLoading] = useState(false);
+ const [isFetchingFiles, setIsFetchingFiles] = useState(false);
+
+ // 업로드할 파일, 제출된 파일 목록
+ const [selectedFile, setSelectedFile] = useState<File | null>(null);
+ const [submittedFiles, setSubmittedFiles] = useState<
+ Array<{ id: number; fileName: string; filePath: string; uploadedAt: Date }>
+ >([]);
+
+ // ===================================
+ // 1) 제출된 파일 목록 가져오기
+ // ===================================
+ const fetchSubmittedFiles = useCallback(async (vendorResponseId: number) => {
+ if (!vendorResponseId ) return;
+
+ setIsFetchingFiles(true);
+ try {
+ const { files, error } = await getTbeSubmittedFiles(vendorResponseId);
+ if (error) {
+ console.error(error);
+ return;
+ }
+ setSubmittedFiles(files);
+ } catch (error) {
+ console.error("Failed to fetch submitted files:", error);
+ } finally {
+ setIsFetchingFiles(false);
+ }
+ }, []);
+
+ // ===================================
+ // 2) TBE 템플릿 다운로드
+ // ===================================
+ const handleDownloadTbeTemplate = useCallback(
+ async (tbeId: number, vendorId: number, rfqId: number) => {
+ setCurrentTbeId(tbeId);
+ setCurrentVendorId(vendorId);
+ setCurrentRfqId(rfqId);
+ setIsLoading(true);
+
+ try {
+ const { files, error } = await fetchTbeTemplateFiles(tbeId);
+ if (error) {
+ toast.error(error);
+ return;
+ }
+ if (files.length === 0) {
+ toast.warning("다운로드할 템플릿 파일이 없습니다");
+ return;
+ }
+ // 순차적으로 파일 다운로드
+ for (const file of files) {
+ await downloadFile(file.id);
+ }
+ toast.success("모든 템플릿 파일이 다운로드되었습니다");
+ } catch (error) {
+ toast.error("템플릿 파일을 다운로드하는 데 실패했습니다");
+ console.error(error);
+ } finally {
+ setIsLoading(false);
+ }
+ },
+ []
+ );
+
+ // 실제 다운로드 로직
+ const downloadFile = useCallback(async (fileId: number) => {
+ try {
+ const { file, error } = await getFileFromRfqAttachmentsbyid(fileId);
+ if (error || !file) {
+ throw new Error(error || "파일 정보를 가져오는 데 실패했습니다");
+ }
+
+ const link = document.createElement("a");
+ link.href = `/api/rfq-download?path=${encodeURIComponent(file.filePath)}`;
+ link.download = file.fileName;
+ document.body.appendChild(link);
+ link.click();
+ document.body.removeChild(link);
+
+ return true;
+ } catch (error) {
+ console.error(error);
+ return false;
+ }
+ }, []);
+
+ // ===================================
+ // 3) 제출된 파일 다운로드
+ // ===================================
+ const downloadSubmittedFile = useCallback((file: { id: number; fileName: string; filePath: string }) => {
+ try {
+ const link = document.createElement("a");
+ link.href = `/api/tbe-download?path=${encodeURIComponent(file.filePath)}`;
+ link.download = file.fileName;
+ document.body.appendChild(link);
+ link.click();
+ document.body.removeChild(link);
+
+ toast.success(`${file.fileName} 다운로드 시작`);
+ } catch (error) {
+ console.error("Failed to download file:", error);
+ toast.error("파일 다운로드에 실패했습니다");
+ }
+ }, []);
+
+ // ===================================
+ // 4) TBE 응답 업로드 모달 열기
+ // (이 시점에서는 데이터 fetch하지 않음)
+ // ===================================
+ const handleUploadTbeResponse = useCallback((tbeId: number, vendorId: number, rfqId: number, vendorResponseId:number) => {
+ setCurrentTbeId(tbeId);
+ setCurrentVendorId(vendorId);
+ setCurrentRfqId(rfqId);
+ setCurrentvendorResponseId(vendorResponseId);
+ setIsUploadDialogOpen(true);
+ }, []);
+
+ // ===================================
+ // 5) Dialog 열고 닫힐 때 상태 초기화
+ // 열렸을 때 -> useEffect로 파일 목록 가져오기
+ // ===================================
+ useEffect(() => {
+ if (!isUploadDialogOpen) {
+ // 닫힐 때는 파일 상태들 초기화
+ setSelectedFile(null);
+ setSubmittedFiles([]);
+ }
+ }, [isUploadDialogOpen]);
+
+ useEffect(() => {
+ // Dialog가 열렸고, ID들이 유효하면
+ if (isUploadDialogOpen &&currentvendorResponseId) {
+ fetchSubmittedFiles(currentvendorResponseId);
+ }
+ }, [isUploadDialogOpen, currentvendorResponseId, fetchSubmittedFiles]);
+
+ // ===================================
+ // 6) 드롭존 파일 선택 & 제거
+ // ===================================
+ const handleFileDrop = useCallback((files: File[]) => {
+ if (files && files.length > 0) {
+ setSelectedFile(files[0]);
+ }
+ }, []);
+
+ const handleRemoveFile = useCallback(() => {
+ setSelectedFile(null);
+ }, []);
+
+ // ===================================
+ // 7) 응답 파일 업로드
+ // ===================================
+ const handleSubmitResponse = useCallback(async () => {
+ if (!selectedFile || !currentTbeId || !currentVendorId || !currentRfqId ||!currentvendorResponseId) {
+ toast.error("업로드할 파일을 선택해주세요");
+ return;
+ }
+
+ setIsLoading(true);
+ try {
+ // FormData 생성
+ const formData = new FormData();
+ formData.append("file", selectedFile);
+ formData.append("rfqId", currentRfqId.toString());
+ formData.append("vendorId", currentVendorId.toString());
+ formData.append("evaluationId", currentTbeId.toString());
+ formData.append("vendorResponseId", currentvendorResponseId.toString());
+
+ const result = await uploadTbeResponseFile(formData);
+ if (!result.success) {
+ throw new Error(result.error || "파일 업로드에 실패했습니다");
+ }
+
+ toast.success(result.message || "응답이 성공적으로 업로드되었습니다");
+
+ // 업로드 후 다시 제출된 파일 목록 가져오기
+ await fetchSubmittedFiles(currentvendorResponseId);
+
+ // 업로드 성공 시 선택 파일 초기화
+ setSelectedFile(null);
+
+ // 페이지 새로고침으로 테이블 데이터 업데이트
+ window.location.reload();
+ } catch (error) {
+ toast.error(error instanceof Error ? error.message : "응답 업로드에 실패했습니다");
+ console.error(error);
+ } finally {
+ setIsLoading(false);
+ }
+ }, [selectedFile, currentTbeId, currentVendorId, currentRfqId, currentvendorResponseId,fetchSubmittedFiles]);
+
+ // ===================================
+ // 8) 실제 Dialog 컴포넌트
+ // ===================================
+ const UploadDialog = () => (
+ <Dialog open={isUploadDialogOpen} onOpenChange={setIsUploadDialogOpen}>
+ <DialogContent className="sm:max-w-lg">
+ <DialogHeader>
+ <DialogTitle>TBE 응답 파일</DialogTitle>
+ <DialogDescription>제출된 파일을 확인하거나 새 파일을 업로드하세요.</DialogDescription>
+ </DialogHeader>
+
+ <Tabs defaultValue="upload" className="w-full">
+ <TabsList className="grid w-full grid-cols-2">
+ <TabsTrigger value="upload">새 파일 업로드</TabsTrigger>
+ <TabsTrigger
+ value="submitted"
+ disabled={submittedFiles.length === 0}
+ className={submittedFiles.length > 0 ? "relative" : ""}
+ >
+ 제출된 파일{" "}
+ {submittedFiles.length > 0 && (
+ <span className="ml-2 inline-flex items-center justify-center rounded-full bg-primary w-4 h-4 text-[10px] text-primary-foreground">
+ {submittedFiles.length}
+ </span>
+ )}
+ </TabsTrigger>
+ </TabsList>
+
+ {/* 업로드 탭 */}
+ <TabsContent value="upload" className="pt-4">
+ <div className="grid gap-4">
+ {selectedFile ? (
+ <FileList>
+ <FileListItem>
+ <FileListIcon />
+ <FileListInfo>
+ <FileListName>{selectedFile.name}</FileListName>
+ <FileListSize>{selectedFile.size}</FileListSize>
+ </FileListInfo>
+ <FileListAction onClick={handleRemoveFile}>
+ <X className="h-4 w-4" />
+ <span className="sr-only">파일 제거</span>
+ </FileListAction>
+ </FileListItem>
+ </FileList>
+ ) : (
+ <Dropzone onDrop={handleFileDrop}>
+ <DropzoneInput className="sr-only" />
+ <DropzoneZone className="flex flex-col items-center justify-center gap-2 p-6">
+ <DropzoneUploadIcon className="h-10 w-10 text-muted-foreground" />
+ <DropzoneTitle>파일을 드래그하거나 클릭하여 업로드</DropzoneTitle>
+ <DropzoneDescription>TBE 응답 파일 (XLSX, XLS, DOCX, PDF 등)</DropzoneDescription>
+ </DropzoneZone>
+ </Dropzone>
+ )}
+
+ <DialogFooter className="mt-4">
+ <Button type="submit" onClick={handleSubmitResponse} disabled={!selectedFile || isLoading}>
+ {isLoading ? "업로드 중..." : "응답 업로드"}
+ </Button>
+ </DialogFooter>
+ </div>
+ </TabsContent>
+
+ {/* 제출된 파일 탭 */}
+ <TabsContent value="submitted" className="pt-4">
+ {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>
+ )}
+ </TabsContent>
+ </Tabs>
+ </DialogContent>
+ </Dialog>
+ );
+
+ // ===================================
+ // 9) Hooks 내보내기
+ // ===================================
+ return {
+ handleDownloadTbeTemplate,
+ handleUploadTbeResponse,
+ UploadDialog,
+ };
+} \ No newline at end of file