summaryrefslogtreecommitdiff
path: root/lib/rfqs/cbe-table
diff options
context:
space:
mode:
authordujinkim <dujin.kim@dtsolution.co.kr>2025-04-28 02:13:30 +0000
committerdujinkim <dujin.kim@dtsolution.co.kr>2025-04-28 02:13:30 +0000
commitef4c533ebacc2cdc97e518f30e9a9350004fcdfb (patch)
tree345251a3ed0f4429716fa5edaa31024d8f4cb560 /lib/rfqs/cbe-table
parent9ceed79cf32c896f8a998399bf1b296506b2cd4a (diff)
~20250428 작업사항
Diffstat (limited to 'lib/rfqs/cbe-table')
-rw-r--r--lib/rfqs/cbe-table/cbe-table-columns.tsx92
-rw-r--r--lib/rfqs/cbe-table/cbe-table-toolbar-actions.tsx67
-rw-r--r--lib/rfqs/cbe-table/cbe-table.tsx123
-rw-r--r--lib/rfqs/cbe-table/comments-sheet.tsx328
-rw-r--r--lib/rfqs/cbe-table/feature-flags-provider.tsx108
-rw-r--r--lib/rfqs/cbe-table/invite-vendors-dialog.tsx423
-rw-r--r--lib/rfqs/cbe-table/vendor-contact-dialog.tsx71
7 files changed, 1012 insertions, 200 deletions
diff --git a/lib/rfqs/cbe-table/cbe-table-columns.tsx b/lib/rfqs/cbe-table/cbe-table-columns.tsx
index 325b0465..bc16496f 100644
--- a/lib/rfqs/cbe-table/cbe-table-columns.tsx
+++ b/lib/rfqs/cbe-table/cbe-table-columns.tsx
@@ -34,8 +34,9 @@ interface GetColumnsProps {
React.SetStateAction<DataTableRowAction<VendorWithCbeFields> | null>
>
router: NextRouter
- openCommentSheet: (vendorId: number) => void
- openFilesDialog: (cbeId:number , vendorId: number) => void
+ openCommentSheet: (responseId: number) => void
+ openVendorContactsDialog: (vendorId: number, vendor: VendorWithCbeFields) => void // 수정된 시그니처
+
}
/**
@@ -45,7 +46,7 @@ export function getColumns({
setRowAction,
router,
openCommentSheet,
- openFilesDialog
+ openVendorContactsDialog
}: GetColumnsProps): ColumnDef<VendorWithCbeFields>[] {
// ----------------------------------------------------------------
// 1) Select 컬럼 (체크박스)
@@ -104,6 +105,30 @@ export function getColumns({
// 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 === "vendorStatus") {
const statusVal = row.original.vendorStatus
if (!statusVal) return null
@@ -116,8 +141,8 @@ export function getColumns({
}
- if (cfg.id === "rfqVendorStatus") {
- const statusVal = row.original.rfqVendorStatus
+ if (cfg.id === "responseStatus") {
+ const statusVal = row.original.responseStatus
if (!statusVal) return null
// const Icon = getStatusIcon(statusVal)
const variant = statusVal ==="INVITED"?"default" :statusVal ==="DECLINED"?"destructive":statusVal ==="ACCEPTED"?"secondary":"outline"
@@ -128,8 +153,8 @@ export function getColumns({
)
}
- // 예) TBE Updated (날짜)
- if (cfg.id === "cbeUpdated") {
+ // 예) CBE Updated (날짜)
+ if (cfg.id === "respondedAt" ) {
const dateVal = val as Date | undefined
if (!dateVal) return null
return formatDate(dateVal)
@@ -172,39 +197,32 @@ const commentsColumn: ColumnDef<VendorWithCbeFields> = {
function handleClick() {
// rowAction + openCommentSheet
setRowAction({ row, type: "comments" })
- openCommentSheet(vendor.cbeId ?? 0)
+ openCommentSheet(vendor.responseId ?? 0)
}
return (
- <div className="flex items-center justify-center">
- <Button
- variant="ghost"
- size="sm"
- className="h-8 w-8 p-0 group relative"
- onClick={handleClick}
- aria-label={commCount > 0 ? `View ${commCount} comments` : "Add comment"}
- >
- <div className="flex items-center justify-center relative">
- {commCount > 0 ? (
- <>
- <MessageSquare className="h-4 w-4 text-muted-foreground group-hover:text-primary transition-colors" />
- <Badge
- variant="secondary"
- className="absolute -top-2 -right-2 h-4 min-w-4 text-xs px-1 flex items-center justify-center"
- >
- {commCount}
- </Badge>
- </>
- ) : (
- <MessageSquare className="h-4 w-4 text-muted-foreground group-hover:text-primary transition-colors" />
- )}
- </div>
- <span className="sr-only">{commCount > 0 ? `${commCount} Comments` : "Add Comment"}</span>
- </Button>
- {/* <span className="ml-2 text-sm text-muted-foreground hover:text-foreground transition-colors cursor-pointer" onClick={handleClick}>
- {commCount > 0 ? `${commCount} Comments` : "Add Comment"}
- </span> */}
- </div>
+ <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,
diff --git a/lib/rfqs/cbe-table/cbe-table-toolbar-actions.tsx b/lib/rfqs/cbe-table/cbe-table-toolbar-actions.tsx
new file mode 100644
index 00000000..fbcf9af9
--- /dev/null
+++ b/lib/rfqs/cbe-table/cbe-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 { VendorWithCbeFields } from "@/config/vendorCbeColumnsConfig"
+import { InviteVendorsDialog } from "./invite-vendors-dialog"
+
+interface VendorsTableToolbarActionsProps {
+ table: Table<VendorWithCbeFields>
+ 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.commercialResponseStatus === 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", "actions"],
+ })
+ }
+ className="gap-2"
+ >
+ <Download className="size-4" aria-hidden="true" />
+ <span className="hidden sm:inline">Export</span>
+ </Button>
+ </div>
+ )
+} \ No newline at end of file
diff --git a/lib/rfqs/cbe-table/cbe-table.tsx b/lib/rfqs/cbe-table/cbe-table.tsx
index b2a74466..37fbc3f4 100644
--- a/lib/rfqs/cbe-table/cbe-table.tsx
+++ b/lib/rfqs/cbe-table/cbe-table.tsx
@@ -8,16 +8,17 @@ import type {
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 { useFeatureFlags } from "./feature-flags-provider"
-import { Vendor, vendors } from "@/db/schema/vendors"
import { fetchRfqAttachmentsbyCommentId, getCBE } from "../service"
-import { TbeComment } from "../tbe-table/comments-sheet"
import { getColumns } from "./cbe-table-columns"
import { VendorWithCbeFields } from "@/config/vendorCbeColumnsConfig"
+import { CommentSheet, CbeComment } from "./comments-sheet"
+import { useSession } from "next-auth/react" // Next-auth session hook 추가
+import { VendorContactsDialog } from "./vendor-contact-dialog"
+import { InviteVendorsDialog } from "./invite-vendors-dialog"
+import { VendorsTableToolbarActions } from "./cbe-table-toolbar-actions"
interface VendorsTableProps {
promises: Promise<
@@ -30,56 +31,54 @@ interface VendorsTableProps {
export function CbeTable({ promises, rfqId }: VendorsTableProps) {
- const { featureFlags } = useFeatureFlags()
// Suspense로 받아온 데이터
const [{ data, pageCount }] = React.use(promises)
+ const { data: session } = useSession() // 세션 정보 가져오기
+
+ const currentUserId = session?.user?.id ? parseInt(session.user.id, 10) : 0
+ const currentUser = session?.user
- console.log(data, "data")
const [rowAction, setRowAction] = React.useState<DataTableRowAction<VendorWithCbeFields> | null>(null)
// **router** 획득
const router = useRouter()
- const [initialComments, setInitialComments] = React.useState<TbeComment[]>([])
+ const [initialComments, setInitialComments] = React.useState<CbeComment[]>([])
const [commentSheetOpen, setCommentSheetOpen] = React.useState(false)
- const [selectedRfqIdForComments, setSelectedRfqIdForComments] = React.useState<number | null>(null)
-
- const [isFileDialogOpen, setIsFileDialogOpen] = React.useState(false)
- const [selectedVendorId, setSelectedVendorId] = React.useState<number | null>(null)
- const [selectedTbeId, setSelectedTbeId] = React.useState<number | null>(null)
+ const [isLoadingComments, setIsLoadingComments] = React.useState(false)
+ // const [selectedRfqIdForComments, setSelectedRfqIdForComments] = React.useState<number | null>(null)
- // Add handleRefresh function
- const handleRefresh = React.useCallback(() => {
- router.refresh();
- }, [router]);
+ const [selectedVendorId, setSelectedVendorId] = React.useState<number | null>(null)
+ const [selectedCbeId, setSelectedCbeId] = React.useState<number | null>(null)
+ const [isContactDialogOpen, setIsContactDialogOpen] = React.useState(false)
+ const [selectedVendor, setSelectedVendor] = React.useState<VendorWithCbeFields | null>(null)
+ // console.log("selectedVendorId", selectedVendorId)
+ // console.log("selectedCbeId", selectedCbeId)
React.useEffect(() => {
if (rowAction?.type === "comments") {
// rowAction가 새로 세팅된 뒤 여기서 openCommentSheet 실행
- openCommentSheet(Number(rowAction.row.original.id))
- } else if (rowAction?.type === "files") {
- // Handle files action
- const vendorId = rowAction.row.original.vendorId;
- const cbeId = rowAction.row.original.cbeId ?? 0;
- openFilesDialog(cbeId, vendorId);
- }
+ openCommentSheet(Number(rowAction.row.original.responseId))
+ }
}, [rowAction])
- async function openCommentSheet(vendorId: number) {
+ async function openCommentSheet(responseId: number) {
setInitialComments([])
-
+ setIsLoadingComments(true)
const comments = rowAction?.row.original.comments
+ // const rfqId = rowAction?.row.original.rfqId
+ const vendorId = rowAction?.row.original.vendorId
if (comments && comments.length > 0) {
- const commentWithAttachments: TbeComment[] = await Promise.all(
+ const commentWithAttachments: CbeComment[] = await Promise.all(
comments.map(async (c) => {
const attachments = await fetchRfqAttachmentsbyCommentId(c.id)
return {
...c,
- commentedBy: 1, // DB나 API 응답에 있다고 가정
+ commentedBy: currentUserId, // DB나 API 응답에 있다고 가정
attachments,
}
})
@@ -88,20 +87,22 @@ export function CbeTable({ promises, rfqId }: VendorsTableProps) {
setInitialComments(commentWithAttachments)
}
- setSelectedRfqIdForComments(vendorId)
+ // if(rfqId){ setSelectedRfqIdForComments(rfqId)}
+ if(vendorId){ setSelectedVendorId(vendorId)}
+ setSelectedCbeId(responseId)
setCommentSheetOpen(true)
+ setIsLoadingComments(false)
}
- const openFilesDialog = (cbeId: number, vendorId: number) => {
- setSelectedTbeId(cbeId)
+ const openVendorContactsDialog = (vendorId: number, vendor: VendorWithCbeFields) => {
setSelectedVendorId(vendorId)
- setIsFileDialogOpen(true)
+ setSelectedVendor(vendor)
+ setIsContactDialogOpen(true)
}
-
// getColumns() 호출 시, router를 주입
const columns = React.useMemo(
- () => getColumns({ setRowAction, router, openCommentSheet, openFilesDialog }),
+ () => getColumns({ setRowAction, router, openCommentSheet, openVendorContactsDialog }),
[setRowAction, router]
)
@@ -111,18 +112,7 @@ export function CbeTable({ promises, rfqId }: VendorsTableProps) {
const advancedFilterFields: DataTableAdvancedFilterField<VendorWithCbeFields>[] = [
{ 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" },
+ { id: "respondedAt", label: "Updated at", type: "date" },
]
@@ -134,32 +124,55 @@ export function CbeTable({ promises, rfqId }: VendorsTableProps) {
enablePinning: true,
enableAdvancedFilter: true,
initialState: {
- sorting: [{ id: "rfqVendorUpdated", desc: true }],
- columnPinning: { right: ["actions"] },
+ sorting: [{ id: "respondedAt", desc: true }],
+ columnPinning: { right: ["comments"] },
},
- getRowId: (originalRow) => String(originalRow.id),
+ getRowId: (originalRow) => String(originalRow.responseId),
shallow: false,
clearOnDefault: true,
})
return (
<>
-<div style={{ maxWidth: '80vw' }}>
-<DataTable
+ <DataTable
table={table}
- // tableContainerClass="sm:max-w-[80vw] md:max-w-[80vw] lg:max-w-[80vw]"
- // tableContainerClass="max-w-[80vw]"
>
<DataTableAdvancedToolbar
table={table}
filterFields={advancedFilterFields}
shallow={false}
>
- {/* <VendorsTableToolbarActions table={table} rfqId={rfqId} /> */}
+ <VendorsTableToolbarActions table={table} rfqId={rfqId} />
</DataTableAdvancedToolbar>
</DataTable>
- </div>
-
+
+ <CommentSheet
+ currentUserId={currentUserId}
+ open={commentSheetOpen}
+ onOpenChange={setCommentSheetOpen}
+ rfqId={rfqId}
+ cbeId={selectedCbeId ?? 0}
+ vendorId={selectedVendorId ?? 0}
+ isLoading={isLoadingComments}
+ initialComments={initialComments}
+ />
+
+ <InviteVendorsDialog
+ vendors={rowAction?.row.original ? [rowAction?.row.original] : []}
+ onOpenChange={() => setRowAction(null)}
+ rfqId={rfqId}
+ open={rowAction?.type === "invite"}
+ showTrigger={false}
+ currentUser={currentUser}
+ />
+
+ <VendorContactsDialog
+ isOpen={isContactDialogOpen}
+ onOpenChange={setIsContactDialogOpen}
+ vendorId={selectedVendorId}
+ vendor={selectedVendor}
+ />
+
</>
)
} \ No newline at end of file
diff --git a/lib/rfqs/cbe-table/comments-sheet.tsx b/lib/rfqs/cbe-table/comments-sheet.tsx
new file mode 100644
index 00000000..e91a0617
--- /dev/null
+++ b/lib/rfqs/cbe-table/comments-sheet.tsx
@@ -0,0 +1,328 @@
+"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 CbeComment {
+ 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?: CbeComment[]
+ currentUserId: number
+ rfqId: number
+ // tbeId?: number
+ cbeId?: number
+ vendorId: number
+ onCommentsUpdated?: (comments: CbeComment[]) => 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,
+ cbeId,
+ onCommentsUpdated,
+ isLoading = false, // Default to false
+ ...props
+}: CommentSheetProps) {
+
+
+ const [comments, setComments] = React.useState<CbeComment[]>(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("cbeId", cbeId)
+ // console.log("currentUserId", currentUserId)
+
+ const res = await createRfqCommentWithAttachments({
+ rfqId,
+ vendorId,
+ commentText: data.commentText,
+ commentedBy: currentUserId,
+ evaluationId: null,
+ cbeId: cbeId,
+ files: data.newFiles,
+ })
+
+ if (!res.ok) {
+ throw new Error("Failed to create comment")
+ }
+
+ toast.success("Comment created")
+
+ // 임시로 새 코멘트 추가
+ const newComment: CbeComment = {
+ 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/cbe-table/feature-flags-provider.tsx b/lib/rfqs/cbe-table/feature-flags-provider.tsx
deleted file mode 100644
index 81131894..00000000
--- a/lib/rfqs/cbe-table/feature-flags-provider.tsx
+++ /dev/null
@@ -1,108 +0,0 @@
-"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/cbe-table/invite-vendors-dialog.tsx b/lib/rfqs/cbe-table/invite-vendors-dialog.tsx
new file mode 100644
index 00000000..18edbe80
--- /dev/null
+++ b/lib/rfqs/cbe-table/invite-vendors-dialog.tsx
@@ -0,0 +1,423 @@
+"use client"
+
+import * as React from "react"
+import { useForm } from "react-hook-form"
+import { zodResolver } from "@hookform/resolvers/zod"
+import { Loader, Send, 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 { Input } from "@/components/ui/input"
+import { Textarea } from "@/components/ui/textarea"
+import {
+ Form,
+ FormControl,
+ FormField,
+ FormItem,
+ FormLabel,
+ FormMessage,
+ FormDescription,
+} from "@/components/ui/form"
+import { type Row } from "@tanstack/react-table"
+import { Badge } from "@/components/ui/badge"
+import { ScrollArea } from "@/components/ui/scroll-area"
+
+import { VendorWithCbeFields } from "@/config/vendorCbeColumnsConfig"
+import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"
+import { createCbeEvaluation } from "../service"
+
+// 컴포넌트 내부에서 사용할 폼 스키마 정의
+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> {
+ rfqId: number
+ vendors: Row<VendorWithCbeFields>["original"][]
+ currentUserId?: number
+ currentUser?: {
+ id: string
+ name?: string | null
+ email?: string | null
+ image?: string | null
+ companyId?: number | null
+ domain?: string | null
+ }
+ showTrigger?: boolean
+ onSuccess?: () => void
+}
+
+export function InviteVendorsDialog({
+ rfqId,
+ vendors,
+ currentUserId,
+ currentUser,
+ showTrigger = true,
+ onSuccess,
+ ...props
+}: InviteVendorsDialogProps) {
+ const [files, setFiles] = React.useState<FileList | null>(null)
+ const isDesktop = useMediaQuery("(min-width: 640px)")
+ const [isSubmitting, setIsSubmitting] = React.useState(false)
+
+ // 로컬 스키마와 폼 값을 사용하도록 수정
+ 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")
+
+ // 디버깅용 상태 트래킹
+ React.useEffect(() => {
+ const subscription = form.watch((value) => {
+ // 폼 값이 변경될 때마다 실행되는 콜백
+ console.log("Form values changed:", value);
+ });
+
+ return () => subscription.unsubscribe();
+ }, [form]);
+
+ async function onSubmit(data: FormValues) {
+ try {
+ setIsSubmitting(true)
+
+ // 기본 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 (currentUserId) {
+ formData.append("evaluatedBy", String(currentUserId))
+ }
+
+ // 협력업체 ID만 추가 (서버에서 연락처 정보를 조회)
+ vendors.forEach((vendor) => {
+ formData.append("vendorIds[]", String(vendor.vendorId))
+ })
+
+ // 파일 추가 (있는 경우에만)
+ 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 평가 생성 중 오류가 발생했습니다.")
+ } finally {
+ setIsSubmitting(false)
+ }
+ }
+
+ function handleDialogOpenChange(nextOpen: boolean) {
+ if (!nextOpen) {
+ form.reset()
+ setFiles(null)
+ }
+ props.onOpenChange?.(nextOpen)
+ }
+
+ // 필수 필드 라벨에 추가할 요소
+ const RequiredLabel = (
+ <span className="text-destructive ml-1 font-medium">*</span>
+ )
+
+ const formContent = (
+ <Form {...form}>
+ <form onSubmit={form.handleSubmit(onSubmit)} 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>
+ <Textarea
+ {...field}
+ placeholder="배송 일정 세부사항을 입력하세요"
+ rows={3}
+ />
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+
+ {/* 비고 - 선택적 필드 */}
+ <FormField
+ control={form.control}
+ name="notes"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>비고</FormLabel>
+ <FormControl>
+ <Textarea
+ {...field}
+ placeholder="추가 비고 사항을 입력하세요"
+ rows={3}
+ />
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+
+ {/* 파일 첨부 (옵션) */}
+ <div className="space-y-2">
+ <FormLabel htmlFor="files">첨부 파일 (선택사항)</FormLabel>
+ <Input
+ id="files"
+ type="file"
+ multiple
+ onChange={(e) => setFiles(e.target.files)}
+ />
+ {files && files.length > 0 && (
+ <p className="text-sm text-muted-foreground">
+ {files.length}개 파일이 첨부되었습니다
+ </p>
+ )}
+ </div>
+
+ {/* 필수 입력 항목 안내 */}
+ <div className="text-sm text-muted-foreground">
+ <span className="text-destructive">*</span> 표시는 필수 입력 항목입니다.
+ </div>
+
+ {/* 모바일에서는 Drawer 내부에서 버튼이 렌더링되므로 여기서는 숨김 */}
+ {isDesktop && (
+ <DialogFooter className="gap-2 pt-4">
+ <DialogClose asChild>
+ <Button
+ type="button"
+ variant="outline"
+ >
+ 취소
+ </Button>
+ </DialogClose>
+ <Button
+ type="submit"
+ disabled={isSubmitting || !isValid}
+ >
+ {isSubmitting && (
+ <Loader className="mr-2 size-4 animate-spin" aria-hidden="true" />
+ )}
+ {vendors.length > 1 ? `${vendors.length}개 협력업체에 전송` : "전송"}
+ </Button>
+ </DialogFooter>
+ )}
+ </form>
+ </Form>
+ )
+
+ // Desktop Dialog
+ if (isDesktop) {
+ return (
+ <Dialog {...props} onOpenChange={handleDialogOpenChange}>
+ {showTrigger ? (
+ <DialogTrigger asChild>
+ <Button variant="outline" size="sm">
+ <Send className="mr-2 size-4" aria-hidden="true" />
+ CBE 평가 전송 ({vendors.length})
+ </Button>
+ </DialogTrigger>
+ ) : null}
+ <DialogContent className="sm:max-w-[600px]">
+ <DialogHeader>
+ <DialogTitle>CBE 평가 생성 및 전송</DialogTitle>
+ <DialogDescription>
+ 선택한 {vendors.length}개 협력업체에 대한 상업 입찰 평가를 생성하고 알림을 전송합니다.
+ </DialogDescription>
+ </DialogHeader>
+
+ {formContent}
+ </DialogContent>
+ </Dialog>
+ )
+ }
+
+ // Mobile Drawer
+ return (
+ <Drawer {...props} onOpenChange={handleDialogOpenChange}>
+ {showTrigger ? (
+ <DrawerTrigger asChild>
+ <Button variant="outline" size="sm">
+ <Send className="mr-2 size-4" aria-hidden="true" />
+ CBE 평가 전송 ({vendors.length})
+ </Button>
+ </DrawerTrigger>
+ ) : null}
+ <DrawerContent>
+ <DrawerHeader>
+ <DrawerTitle>CBE 평가 생성 및 전송</DrawerTitle>
+ <DrawerDescription>
+ 선택한 {vendors.length}개 협력업체에 대한 상업 입찰 평가를 생성하고 알림을 전송합니다.
+ </DrawerDescription>
+ </DrawerHeader>
+
+ <div className="px-4">
+ {formContent}
+ </div>
+
+ <DrawerFooter className="gap-2 sm:space-x-0">
+ <DrawerClose asChild>
+ <Button variant="outline">취소</Button>
+ </DrawerClose>
+ <Button
+ onClick={form.handleSubmit(onSubmit)}
+ disabled={isSubmitting || !isValid}
+ >
+ {isSubmitting && (
+ <Loader className="mr-2 size-4 animate-spin" aria-hidden="true" />
+ )}
+ {vendors.length > 1 ? `${vendors.length}개 협력업체에 전송` : "전송"}
+ </Button>
+ </DrawerFooter>
+ </DrawerContent>
+ </Drawer>
+ )
+} \ No newline at end of file
diff --git a/lib/rfqs/cbe-table/vendor-contact-dialog.tsx b/lib/rfqs/cbe-table/vendor-contact-dialog.tsx
new file mode 100644
index 00000000..180db392
--- /dev/null
+++ b/lib/rfqs/cbe-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 { Badge } from "@/components/ui/badge"
+import { VendorWithCbeFields } from "@/config/vendorCbeColumnsConfig"
+import { VendorContactsTable } from "../tbe-table/vendor-contact/vendor-contact-table"
+
+interface VendorContactsDialogProps {
+ isOpen: boolean
+ onOpenChange: (open: boolean) => void
+ vendorId: number | null
+ vendor: VendorWithCbeFields | 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.commercialResponseStatus && (
+ <Badge
+ variant={
+ vendor.commercialResponseStatus === "INVITED" ? "default" :
+ vendor.commercialResponseStatus === "DECLINED" ? "destructive" :
+ vendor.commercialResponseStatus === "ACCEPTED" ? "secondary" : "outline"
+ }
+ >
+ {vendor.commercialResponseStatus}
+ </Badge>
+ )}
+ </div>
+ </div>
+ )}
+ </div>
+ </DialogHeader>
+ {vendorId && (
+ <div className="py-4">
+ <VendorContactsTable vendorId={vendorId} />
+ </div>
+ )}
+ </DialogContent>
+ </Dialog>
+ )
+} \ No newline at end of file